diff --git a/pkg/invoke/args.go b/pkg/invoke/args.go new file mode 100644 index 00000000..ba9d0c3b --- /dev/null +++ b/pkg/invoke/args.go @@ -0,0 +1,79 @@ +// Copyright 2015 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 ( + "os" + "strings" +) + +type CNIArgs interface { + // For use with os/exec; i.e., return nil to inherit the + // environment from this process + AsEnv() []string +} + +type inherited struct{} + +var inheritArgsFromEnv inherited + +func (_ *inherited) AsEnv() []string { + return nil +} + +func ArgsFromEnv() CNIArgs { + return &inheritArgsFromEnv +} + +type Args struct { + Command string + ContainerID string + NetNS string + PluginArgs [][2]string + PluginArgsStr string + IfName string + Path string +} + +// Args implements the CNIArgs interface +var _ CNIArgs = &Args{} + +func (args *Args) AsEnv() []string { + env := os.Environ() + pluginArgsStr := args.PluginArgsStr + if pluginArgsStr == "" { + pluginArgsStr = stringify(args.PluginArgs) + } + + env = append(env, + "CNI_COMMAND="+args.Command, + "CNI_CONTAINERID="+args.ContainerID, + "CNI_NETNS="+args.NetNS, + "CNI_ARGS="+pluginArgsStr, + "CNI_IFNAME="+args.IfName, + "CNI_PATH="+args.Path) + return env +} + +// taken from rkt/networking/net_plugin.go +func stringify(pluginArgs [][2]string) string { + entries := make([]string, len(pluginArgs)) + + for i, kv := range pluginArgs { + entries[i] = strings.Join(kv[:], "=") + } + + return strings.Join(entries, ";") +} diff --git a/pkg/invoke/delegate.go b/pkg/invoke/delegate.go new file mode 100644 index 00000000..c78a69ee --- /dev/null +++ b/pkg/invoke/delegate.go @@ -0,0 +1,53 @@ +// 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 ( + "fmt" + "os" + "path/filepath" + + "github.com/containernetworking/cni/pkg/types" +) + +func DelegateAdd(delegatePlugin string, netconf []byte) (types.Result, error) { + if os.Getenv("CNI_COMMAND") != "ADD" { + return nil, fmt.Errorf("CNI_COMMAND is not ADD") + } + + paths := filepath.SplitList(os.Getenv("CNI_PATH")) + + pluginPath, err := FindInPath(delegatePlugin, paths) + if err != nil { + return nil, err + } + + return ExecPluginWithResult(pluginPath, netconf, ArgsFromEnv()) +} + +func DelegateDel(delegatePlugin string, netconf []byte) error { + if os.Getenv("CNI_COMMAND") != "DEL" { + return fmt.Errorf("CNI_COMMAND is not DEL") + } + + paths := filepath.SplitList(os.Getenv("CNI_PATH")) + + pluginPath, err := FindInPath(delegatePlugin, paths) + if err != nil { + return err + } + + return ExecPluginWithoutResult(pluginPath, netconf, ArgsFromEnv()) +} diff --git a/pkg/invoke/exec.go b/pkg/invoke/exec.go new file mode 100644 index 00000000..fc47e7c8 --- /dev/null +++ b/pkg/invoke/exec.go @@ -0,0 +1,95 @@ +// Copyright 2015 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 ( + "fmt" + "os" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/version" +) + +func ExecPluginWithResult(pluginPath string, netconf []byte, args CNIArgs) (types.Result, error) { + return defaultPluginExec.WithResult(pluginPath, netconf, args) +} + +func ExecPluginWithoutResult(pluginPath string, netconf []byte, args CNIArgs) error { + return defaultPluginExec.WithoutResult(pluginPath, netconf, args) +} + +func GetVersionInfo(pluginPath string) (version.PluginInfo, error) { + return defaultPluginExec.GetVersionInfo(pluginPath) +} + +var defaultPluginExec = &PluginExec{ + RawExec: &RawExec{Stderr: os.Stderr}, + VersionDecoder: &version.PluginDecoder{}, +} + +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 + } + + // Plugin must return result in same version as specified in netconf + versionDecoder := &version.ConfigDecoder{} + confVersion, err := versionDecoder.Decode(netconf) + if err != nil { + return nil, err + } + + return version.NewResult(confVersion, stdoutBytes) +} + +func (e *PluginExec) WithoutResult(pluginPath string, netconf []byte, args CNIArgs) error { + _, err := e.RawExec.ExecPlugin(pluginPath, netconf, args.AsEnv()) + return err +} + +// GetVersionInfo returns the version information available about the plugin. +// For recent-enough plugins, it uses the information returned by the VERSION +// command. For older plugins which do not recognize that command, it reports +// version 0.1.0 +func (e *PluginExec) GetVersionInfo(pluginPath string) (version.PluginInfo, error) { + args := &Args{ + Command: "VERSION", + + // set fake values required by plugins built against an older version of skel + NetNS: "dummy", + IfName: "dummy", + Path: "dummy", + } + stdin := []byte(fmt.Sprintf(`{"cniVersion":%q}`, version.Current())) + stdoutBytes, err := e.RawExec.ExecPlugin(pluginPath, stdin, args.AsEnv()) + if err != nil { + if err.Error() == "unknown CNI_COMMAND: VERSION" { + return version.PluginSupports("0.1.0"), nil + } + return nil, err + } + + return e.VersionDecoder.Decode(stdoutBytes) +} diff --git a/pkg/invoke/exec_test.go b/pkg/invoke/exec_test.go new file mode 100644 index 00000000..33ffc2de --- /dev/null +++ b/pkg/invoke/exec_test.go @@ -0,0 +1,157 @@ +// 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 ( + "encoding/json" + "errors" + + "github.com/containernetworking/cni/pkg/invoke" + "github.com/containernetworking/cni/pkg/invoke/fakes" + "github.com/containernetworking/cni/pkg/types/current" + "github.com/containernetworking/cni/pkg/version" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Executing a plugin, unit tests", func() { + var ( + pluginExec *invoke.PluginExec + rawExec *fakes.RawExec + versionDecoder *fakes.VersionDecoder + + pluginPath string + netconf []byte + cniargs *fakes.CNIArgs + ) + + BeforeEach(func() { + rawExec = &fakes.RawExec{} + rawExec.ExecPluginCall.Returns.ResultBytes = []byte(`{ "ips": [ { "version": "4", "address": "1.2.3.4/24" } ] }`) + + versionDecoder = &fakes.VersionDecoder{} + versionDecoder.DecodeCall.Returns.PluginInfo = version.PluginSupports("0.42.0") + + pluginExec = &invoke.PluginExec{ + RawExec: rawExec, + VersionDecoder: versionDecoder, + } + pluginPath = "/some/plugin/path" + netconf = []byte(`{ "some": "stdin", "cniVersion": "0.3.1" }`) + cniargs = &fakes.CNIArgs{} + cniargs.AsEnvCall.Returns.Env = []string{"SOME=ENV"} + }) + + Describe("returning a result", func() { + It("unmarshals the result bytes into the Result type", func() { + r, err := pluginExec.WithResult(pluginPath, netconf, cniargs) + Expect(err).NotTo(HaveOccurred()) + + result, err := current.GetResult(r) + Expect(err).NotTo(HaveOccurred()) + Expect(len(result.IPs)).To(Equal(1)) + Expect(result.IPs[0].Address.IP.String()).To(Equal("1.2.3.4")) + }) + + 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")) + }) + }) + }) + + 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() { + rawExec.ExecPluginCall.Returns.ResultBytes = []byte(`{ "some": "version-info" }`) + }) + + It("execs the plugin with the command VERSION", func() { + pluginExec.GetVersionInfo(pluginPath) + Expect(rawExec.ExecPluginCall.Received.PluginPath).To(Equal(pluginPath)) + Expect(rawExec.ExecPluginCall.Received.Environ).To(ContainElement("CNI_COMMAND=VERSION")) + expectedStdin, _ := json.Marshal(map[string]string{"cniVersion": version.Current()}) + Expect(rawExec.ExecPluginCall.Received.StdinData).To(MatchJSON(expectedStdin)) + }) + + It("decodes and returns the version info", func() { + versionInfo, err := pluginExec.GetVersionInfo(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.GetVersionInfo(pluginPath) + Expect(err).To(MatchError("banana")) + }) + }) + + Context("when the plugin is too old to recognize the VERSION command", func() { + BeforeEach(func() { + rawExec.ExecPluginCall.Returns.Error = errors.New("unknown CNI_COMMAND: VERSION") + }) + + It("interprets the error as a 0.1.0 version", func() { + versionInfo, err := pluginExec.GetVersionInfo(pluginPath) + Expect(err).NotTo(HaveOccurred()) + Expect(versionInfo.SupportedVersions()).To(ConsistOf("0.1.0")) + }) + + It("sets dummy values for env vars required by very old plugins", func() { + pluginExec.GetVersionInfo(pluginPath) + + env := rawExec.ExecPluginCall.Received.Environ + Expect(env).To(ContainElement("CNI_NETNS=dummy")) + Expect(env).To(ContainElement("CNI_IFNAME=dummy")) + Expect(env).To(ContainElement("CNI_PATH=dummy")) + }) + }) + }) +}) diff --git a/pkg/invoke/fakes/cni_args.go b/pkg/invoke/fakes/cni_args.go new file mode 100644 index 00000000..5b1ba29e --- /dev/null +++ b/pkg/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/pkg/invoke/fakes/raw_exec.go b/pkg/invoke/fakes/raw_exec.go new file mode 100644 index 00000000..5432cdf7 --- /dev/null +++ b/pkg/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/pkg/invoke/fakes/version_decoder.go b/pkg/invoke/fakes/version_decoder.go new file mode 100644 index 00000000..72d29733 --- /dev/null +++ b/pkg/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/pkg/invoke/find.go b/pkg/invoke/find.go new file mode 100644 index 00000000..e815404c --- /dev/null +++ b/pkg/invoke/find.go @@ -0,0 +1,43 @@ +// Copyright 2015 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 ( + "fmt" + "os" + "path/filepath" +) + +// FindInPath returns the full path of the plugin by searching in the provided path +func FindInPath(plugin string, paths []string) (string, error) { + if plugin == "" { + return "", fmt.Errorf("no plugin name provided") + } + + if len(paths) == 0 { + return "", fmt.Errorf("no paths provided") + } + + for _, path := range paths { + for _, fe := range ExecutableFileExtensions { + fullpath := filepath.Join(path, plugin) + fe + if fi, err := os.Stat(fullpath); err == nil && fi.Mode().IsRegular() { + return fullpath, nil + } + } + } + + return "", fmt.Errorf("failed to find plugin %q in path %s", plugin, paths) +} diff --git a/pkg/invoke/find_test.go b/pkg/invoke/find_test.go new file mode 100644 index 00000000..58543131 --- /dev/null +++ b/pkg/invoke/find_test.go @@ -0,0 +1,103 @@ +// 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 ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/containernetworking/cni/pkg/invoke" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("FindInPath", func() { + var ( + multiplePaths []string + pluginName string + plugin2NameWithExt string + plugin2NameWithoutExt string + pluginDir string + anotherTempDir string + ) + + BeforeEach(func() { + tempDir, err := ioutil.TempDir("", "cni-find") + Expect(err).NotTo(HaveOccurred()) + + plugin, err := ioutil.TempFile(tempDir, "a-cni-plugin") + Expect(err).NotTo(HaveOccurred()) + + plugin2Name := "a-plugin-with-extension" + invoke.ExecutableFileExtensions[0] + plugin2, err := os.Create(filepath.Join(tempDir, plugin2Name)) + Expect(err).NotTo(HaveOccurred()) + + anotherTempDir, err = ioutil.TempDir("", "nothing-here") + Expect(err).NotTo(HaveOccurred()) + + multiplePaths = []string{anotherTempDir, tempDir} + pluginDir, pluginName = filepath.Split(plugin.Name()) + _, plugin2NameWithExt = filepath.Split(plugin2.Name()) + plugin2NameWithoutExt = strings.Split(plugin2NameWithExt, ".")[0] + }) + + AfterEach(func() { + os.RemoveAll(pluginDir) + os.RemoveAll(anotherTempDir) + }) + + Context("when multiple paths are provided", func() { + It("returns only the path to the plugin", func() { + pluginPath, err := invoke.FindInPath(pluginName, multiplePaths) + Expect(err).NotTo(HaveOccurred()) + Expect(pluginPath).To(Equal(filepath.Join(pluginDir, pluginName))) + }) + }) + + Context("when a plugin name without its file name extension is provided", func() { + It("returns the path to the plugin, including its extension", func() { + pluginPath, err := invoke.FindInPath(plugin2NameWithoutExt, multiplePaths) + Expect(err).NotTo(HaveOccurred()) + Expect(pluginPath).To(Equal(filepath.Join(pluginDir, plugin2NameWithExt))) + }) + }) + + Context("when an error occurs", func() { + Context("when no paths are provided", func() { + It("returns an error noting no paths were provided", func() { + _, err := invoke.FindInPath(pluginName, []string{}) + Expect(err).To(MatchError("no paths provided")) + }) + }) + + Context("when no plugin is provided", func() { + It("returns an error noting the plugin name wasn't found", func() { + _, err := invoke.FindInPath("", multiplePaths) + Expect(err).To(MatchError("no plugin name provided")) + }) + }) + + Context("when the plugin cannot be found", func() { + It("returns an error noting the path", func() { + pathsWithNothing := []string{anotherTempDir} + _, err := invoke.FindInPath(pluginName, pathsWithNothing) + Expect(err).To(MatchError(fmt.Sprintf("failed to find plugin %q in path %s", pluginName, pathsWithNothing))) + }) + }) + }) +}) diff --git a/pkg/invoke/get_version_integration_test.go b/pkg/invoke/get_version_integration_test.go new file mode 100644 index 00000000..7e58a9be --- /dev/null +++ b/pkg/invoke/get_version_integration_test.go @@ -0,0 +1,107 @@ +// 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 ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/containernetworking/cni/pkg/invoke" + "github.com/containernetworking/cni/pkg/version" + "github.com/containernetworking/cni/pkg/version/testhelpers" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetVersion, integration tests", func() { + var ( + pluginDir string + pluginPath string + ) + + BeforeEach(func() { + pluginDir, err := ioutil.TempDir("", "plugins") + Expect(err).NotTo(HaveOccurred()) + pluginPath = filepath.Join(pluginDir, "test-plugin") + }) + + AfterEach(func() { + Expect(os.RemoveAll(pluginDir)).To(Succeed()) + }) + + DescribeTable("correctly reporting plugin versions", + func(gitRef string, pluginSource string, expectedVersions version.PluginInfo) { + Expect(testhelpers.BuildAt([]byte(pluginSource), gitRef, pluginPath)).To(Succeed()) + versionInfo, err := invoke.GetVersionInfo(pluginPath) + Expect(err).NotTo(HaveOccurred()) + + Expect(versionInfo.SupportedVersions()).To(ConsistOf(expectedVersions.SupportedVersions())) + }, + + Entry("historical: before VERSION was introduced", + git_ref_v010, plugin_source_no_custom_versions, + version.PluginSupports("0.1.0"), + ), + + Entry("historical: when VERSION was introduced but plugins couldn't customize it", + git_ref_v020_no_custom_versions, plugin_source_no_custom_versions, + version.PluginSupports("0.1.0", "0.2.0"), + ), + + Entry("historical: when plugins started reporting their own version list", + git_ref_v020_custom_versions, plugin_source_v020_custom_versions, + version.PluginSupports("0.2.0", "0.999.0"), + ), + + // this entry tracks the current behavior. Before you change it, ensure + // that its previous behavior is captured in the most recent "historical" entry + Entry("current", + "HEAD", plugin_source_v020_custom_versions, + version.PluginSupports("0.2.0", "0.999.0"), + ), + ) +}) + +// a 0.2.0 plugin that can report its own versions +const plugin_source_v020_custom_versions = `package main + +import ( + "github.com/containernetworking/cni/pkg/skel" + "github.com/containernetworking/cni/pkg/version" + "fmt" +) + +func c(_ *skel.CmdArgs) error { fmt.Println("{}"); return nil } + +func main() { skel.PluginMain(c, c, version.PluginSupports("0.2.0", "0.999.0")) } +` +const git_ref_v020_custom_versions = "bf31ed15" + +// a minimal 0.1.0 / 0.2.0 plugin that cannot report it's own version support +const plugin_source_no_custom_versions = `package main + +import "github.com/containernetworking/cni/pkg/skel" +import "fmt" + +func c(_ *skel.CmdArgs) error { fmt.Println("{}"); return nil } + +func main() { skel.PluginMain(c, c) } +` + +const git_ref_v010 = "2c482f4" +const git_ref_v020_no_custom_versions = "349d66d" diff --git a/pkg/invoke/invoke_suite_test.go b/pkg/invoke/invoke_suite_test.go new file mode 100644 index 00000000..7285878a --- /dev/null +++ b/pkg/invoke/invoke_suite_test.go @@ -0,0 +1,45 @@ +// 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 ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gexec" + + "testing" +) + +func TestInvoke(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Invoke Suite") +} + +const packagePath = "github.com/containernetworking/cni/plugins/test/noop" + +var pathToPlugin string + +var _ = SynchronizedBeforeSuite(func() []byte { + var err error + pathToPlugin, err = gexec.Build(packagePath) + Expect(err).NotTo(HaveOccurred()) + return []byte(pathToPlugin) +}, func(crossNodeData []byte) { + pathToPlugin = string(crossNodeData) +}) + +var _ = SynchronizedAfterSuite(func() {}, func() { + gexec.CleanupBuildArtifacts() +}) diff --git a/pkg/invoke/os_unix.go b/pkg/invoke/os_unix.go new file mode 100644 index 00000000..bab5737a --- /dev/null +++ b/pkg/invoke/os_unix.go @@ -0,0 +1,20 @@ +// 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. + +// +build darwin dragonfly freebsd linux netbsd opensbd solaris + +package invoke + +// Valid file extensions for plugin executables. +var ExecutableFileExtensions = []string{""} diff --git a/pkg/invoke/os_windows.go b/pkg/invoke/os_windows.go new file mode 100644 index 00000000..7665125b --- /dev/null +++ b/pkg/invoke/os_windows.go @@ -0,0 +1,18 @@ +// 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 + +// Valid file extensions for plugin executables. +var ExecutableFileExtensions = []string{".exe", ""} diff --git a/pkg/invoke/raw_exec.go b/pkg/invoke/raw_exec.go new file mode 100644 index 00000000..d1bd860d --- /dev/null +++ b/pkg/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/pkg/invoke/raw_exec_test.go b/pkg/invoke/raw_exec_test.go new file mode 100644 index 00000000..5d759f24 --- /dev/null +++ b/pkg/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", "cniVersion": "0.3.1"}`) + 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/pkg/ip/cidr.go b/pkg/ip/cidr.go new file mode 100644 index 00000000..dae2c4d0 --- /dev/null +++ b/pkg/ip/cidr.go @@ -0,0 +1,51 @@ +// Copyright 2015 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 ip + +import ( + "math/big" + "net" +) + +// NextIP returns IP incremented by 1 +func NextIP(ip net.IP) net.IP { + i := ipToInt(ip) + return intToIP(i.Add(i, big.NewInt(1))) +} + +// PrevIP returns IP decremented by 1 +func PrevIP(ip net.IP) net.IP { + i := ipToInt(ip) + return intToIP(i.Sub(i, big.NewInt(1))) +} + +func ipToInt(ip net.IP) *big.Int { + if v := ip.To4(); v != nil { + return big.NewInt(0).SetBytes(v) + } + return big.NewInt(0).SetBytes(ip.To16()) +} + +func intToIP(i *big.Int) net.IP { + return net.IP(i.Bytes()) +} + +// Network masks off the host portion of the IP +func Network(ipn *net.IPNet) *net.IPNet { + return &net.IPNet{ + IP: ipn.IP.Mask(ipn.Mask), + Mask: ipn.Mask, + } +} diff --git a/pkg/ip/ip_suite_test.go b/pkg/ip/ip_suite_test.go new file mode 100644 index 00000000..3fdd57e4 --- /dev/null +++ b/pkg/ip/ip_suite_test.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 ip_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestIp(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Ip Suite") +} diff --git a/pkg/ip/ipforward.go b/pkg/ip/ipforward.go new file mode 100644 index 00000000..77ee7463 --- /dev/null +++ b/pkg/ip/ipforward.go @@ -0,0 +1,31 @@ +// Copyright 2015 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 ip + +import ( + "io/ioutil" +) + +func EnableIP4Forward() error { + return echo1("/proc/sys/net/ipv4/ip_forward") +} + +func EnableIP6Forward() error { + return echo1("/proc/sys/net/ipv6/conf/all/forwarding") +} + +func echo1(f string) error { + return ioutil.WriteFile(f, []byte("1"), 0644) +} diff --git a/pkg/ip/ipmasq.go b/pkg/ip/ipmasq.go new file mode 100644 index 00000000..8ee27971 --- /dev/null +++ b/pkg/ip/ipmasq.go @@ -0,0 +1,66 @@ +// Copyright 2015 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 ip + +import ( + "fmt" + "net" + + "github.com/coreos/go-iptables/iptables" +) + +// SetupIPMasq installs iptables rules to masquerade traffic +// coming from ipn and going outside of it +func SetupIPMasq(ipn *net.IPNet, chain string, comment string) error { + ipt, err := iptables.New() + if err != nil { + return fmt.Errorf("failed to locate iptables: %v", err) + } + + if err = ipt.NewChain("nat", chain); err != nil { + if err.(*iptables.Error).ExitStatus() != 1 { + // TODO(eyakubovich): assumes exit status 1 implies chain exists + return err + } + } + + if err = ipt.AppendUnique("nat", chain, "-d", ipn.String(), "-j", "ACCEPT", "-m", "comment", "--comment", comment); err != nil { + return err + } + + if err = ipt.AppendUnique("nat", chain, "!", "-d", "224.0.0.0/4", "-j", "MASQUERADE", "-m", "comment", "--comment", comment); err != nil { + return err + } + + return ipt.AppendUnique("nat", "POSTROUTING", "-s", ipn.String(), "-j", chain, "-m", "comment", "--comment", comment) +} + +// TeardownIPMasq undoes the effects of SetupIPMasq +func TeardownIPMasq(ipn *net.IPNet, chain string, comment string) error { + ipt, err := iptables.New() + if err != nil { + return fmt.Errorf("failed to locate iptables: %v", err) + } + + if err = ipt.Delete("nat", "POSTROUTING", "-s", ipn.String(), "-j", chain, "-m", "comment", "--comment", comment); err != nil { + return err + } + + if err = ipt.ClearChain("nat", chain); err != nil { + return err + } + + return ipt.DeleteChain("nat", chain) +} diff --git a/pkg/ip/link.go b/pkg/ip/link.go new file mode 100644 index 00000000..a9842627 --- /dev/null +++ b/pkg/ip/link.go @@ -0,0 +1,219 @@ +// Copyright 2015 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 ip + +import ( + "crypto/rand" + "errors" + "fmt" + "net" + "os" + + "github.com/containernetworking/cni/pkg/ns" + "github.com/containernetworking/cni/pkg/utils/hwaddr" + "github.com/vishvananda/netlink" +) + +var ( + ErrLinkNotFound = errors.New("link not found") +) + +func makeVethPair(name, peer string, mtu int) (netlink.Link, error) { + veth := &netlink.Veth{ + LinkAttrs: netlink.LinkAttrs{ + Name: name, + Flags: net.FlagUp, + MTU: mtu, + }, + PeerName: peer, + } + if err := netlink.LinkAdd(veth); err != nil { + return nil, err + } + + return veth, nil +} + +func peerExists(name string) bool { + if _, err := netlink.LinkByName(name); err != nil { + return false + } + return true +} + +func makeVeth(name string, mtu int) (peerName string, veth netlink.Link, err error) { + for i := 0; i < 10; i++ { + peerName, err = RandomVethName() + if err != nil { + return + } + + veth, err = makeVethPair(name, peerName, mtu) + switch { + case err == nil: + return + + case os.IsExist(err): + if peerExists(peerName) { + continue + } + err = fmt.Errorf("container veth name provided (%v) already exists", name) + return + + default: + err = fmt.Errorf("failed to make veth pair: %v", err) + return + } + } + + // should really never be hit + err = fmt.Errorf("failed to find a unique veth name") + return +} + +// RandomVethName returns string "veth" with random prefix (hashed from entropy) +func RandomVethName() (string, error) { + entropy := make([]byte, 4) + _, err := rand.Reader.Read(entropy) + if err != nil { + return "", fmt.Errorf("failed to generate random veth name: %v", err) + } + + // NetworkManager (recent versions) will ignore veth devices that start with "veth" + return fmt.Sprintf("veth%x", entropy), nil +} + +func RenameLink(curName, newName string) error { + link, err := netlink.LinkByName(curName) + if err == nil { + err = netlink.LinkSetName(link, newName) + } + return err +} + +func ifaceFromNetlinkLink(l netlink.Link) net.Interface { + a := l.Attrs() + return net.Interface{ + Index: a.Index, + MTU: a.MTU, + Name: a.Name, + HardwareAddr: a.HardwareAddr, + Flags: a.Flags, + } +} + +// SetupVeth sets up a pair of virtual ethernet devices. +// Call SetupVeth from inside the container netns. It will create both veth +// devices and move the host-side veth into the provided hostNS namespace. +// On success, SetupVeth returns (hostVeth, containerVeth, nil) +func SetupVeth(contVethName string, mtu int, hostNS ns.NetNS) (net.Interface, net.Interface, error) { + hostVethName, contVeth, err := makeVeth(contVethName, mtu) + if err != nil { + return net.Interface{}, net.Interface{}, err + } + + if err = netlink.LinkSetUp(contVeth); err != nil { + return net.Interface{}, net.Interface{}, fmt.Errorf("failed to set %q up: %v", contVethName, err) + } + + hostVeth, err := netlink.LinkByName(hostVethName) + if err != nil { + return net.Interface{}, net.Interface{}, fmt.Errorf("failed to lookup %q: %v", hostVethName, err) + } + + if err = netlink.LinkSetNsFd(hostVeth, int(hostNS.Fd())); err != nil { + return net.Interface{}, net.Interface{}, fmt.Errorf("failed to move veth to host netns: %v", err) + } + + err = hostNS.Do(func(_ ns.NetNS) error { + hostVeth, err = netlink.LinkByName(hostVethName) + if err != nil { + return fmt.Errorf("failed to lookup %q in %q: %v", hostVethName, hostNS.Path(), err) + } + + if err = netlink.LinkSetUp(hostVeth); err != nil { + return fmt.Errorf("failed to set %q up: %v", hostVethName, err) + } + return nil + }) + if err != nil { + return net.Interface{}, net.Interface{}, err + } + return ifaceFromNetlinkLink(hostVeth), ifaceFromNetlinkLink(contVeth), nil +} + +// DelLinkByName removes an interface link. +func DelLinkByName(ifName string) error { + iface, err := netlink.LinkByName(ifName) + if err != nil { + return fmt.Errorf("failed to lookup %q: %v", ifName, err) + } + + if err = netlink.LinkDel(iface); err != nil { + return fmt.Errorf("failed to delete %q: %v", ifName, err) + } + + return nil +} + +// DelLinkByNameAddr remove an interface returns its IP address +// of the specified family +func DelLinkByNameAddr(ifName string, family int) (*net.IPNet, error) { + iface, err := netlink.LinkByName(ifName) + if err != nil { + if err != nil && err.Error() == "Link not found" { + return nil, ErrLinkNotFound + } + return nil, fmt.Errorf("failed to lookup %q: %v", ifName, err) + } + + addrs, err := netlink.AddrList(iface, family) + if err != nil || len(addrs) == 0 { + return nil, fmt.Errorf("failed to get IP addresses for %q: %v", ifName, err) + } + + if err = netlink.LinkDel(iface); err != nil { + return nil, fmt.Errorf("failed to delete %q: %v", ifName, err) + } + + return addrs[0].IPNet, nil +} + +func SetHWAddrByIP(ifName string, ip4 net.IP, ip6 net.IP) error { + iface, err := netlink.LinkByName(ifName) + if err != nil { + return fmt.Errorf("failed to lookup %q: %v", ifName, err) + } + + switch { + case ip4 == nil && ip6 == nil: + return fmt.Errorf("neither ip4 or ip6 specified") + + case ip4 != nil: + { + hwAddr, err := hwaddr.GenerateHardwareAddr4(ip4, hwaddr.PrivateMACPrefix) + if err != nil { + return fmt.Errorf("failed to generate hardware addr: %v", err) + } + if err = netlink.LinkSetHardwareAddr(iface, hwAddr); err != nil { + return fmt.Errorf("failed to add hardware addr to %q: %v", ifName, err) + } + } + case ip6 != nil: + // TODO: IPv6 + } + + return nil +} diff --git a/pkg/ip/link_test.go b/pkg/ip/link_test.go new file mode 100644 index 00000000..23182a54 --- /dev/null +++ b/pkg/ip/link_test.go @@ -0,0 +1,273 @@ +// 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 ip_test + +import ( + "bytes" + "crypto/rand" + "fmt" + "net" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/containernetworking/cni/pkg/ip" + "github.com/containernetworking/cni/pkg/ns" + + "github.com/vishvananda/netlink" + "github.com/vishvananda/netlink/nl" +) + +func getHwAddr(linkname string) string { + veth, err := netlink.LinkByName(linkname) + Expect(err).NotTo(HaveOccurred()) + return fmt.Sprintf("%s", veth.Attrs().HardwareAddr) +} + +var _ = Describe("Link", func() { + const ( + ifaceFormatString string = "i%d" + mtu int = 1400 + ip4onehwaddr = "0a:58:01:01:01:01" + ) + var ( + hostNetNS ns.NetNS + containerNetNS ns.NetNS + ifaceCounter int = 0 + hostVeth net.Interface + containerVeth net.Interface + hostVethName string + containerVethName string + + ip4one = net.ParseIP("1.1.1.1") + ip4two = net.ParseIP("1.1.1.2") + originalRandReader = rand.Reader + ) + + BeforeEach(func() { + var err error + + hostNetNS, err = ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + containerNetNS, err = ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + fakeBytes := make([]byte, 20) + //to be reset in AfterEach block + rand.Reader = bytes.NewReader(fakeBytes) + + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + hostVeth, containerVeth, err = ip.SetupVeth(fmt.Sprintf(ifaceFormatString, ifaceCounter), mtu, hostNetNS) + if err != nil { + return err + } + Expect(err).NotTo(HaveOccurred()) + + hostVethName = hostVeth.Name + containerVethName = containerVeth.Name + + return nil + }) + }) + + AfterEach(func() { + Expect(containerNetNS.Close()).To(Succeed()) + Expect(hostNetNS.Close()).To(Succeed()) + ifaceCounter++ + rand.Reader = originalRandReader + }) + + It("SetupVeth must put the veth endpoints into the separate namespaces", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + containerVethFromName, err := netlink.LinkByName(containerVethName) + Expect(err).NotTo(HaveOccurred()) + Expect(containerVethFromName.Attrs().Index).To(Equal(containerVeth.Index)) + + return nil + }) + + _ = hostNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + hostVethFromName, err := netlink.LinkByName(hostVethName) + Expect(err).NotTo(HaveOccurred()) + Expect(hostVethFromName.Attrs().Index).To(Equal(hostVeth.Index)) + + return nil + }) + }) + + Context("when container already has an interface with the same name", func() { + It("returns useful error", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + _, _, err := ip.SetupVeth(containerVethName, mtu, hostNetNS) + Expect(err.Error()).To(Equal(fmt.Sprintf("container veth name provided (%s) already exists", containerVethName))) + + return nil + }) + }) + }) + + Context("deleting an non-existent device", func() { + It("returns known error", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + // This string should match the expected error codes in the cmdDel functions of some of the plugins + _, err := ip.DelLinkByNameAddr("THIS_DONT_EXIST", netlink.FAMILY_V4) + Expect(err).To(Equal(ip.ErrLinkNotFound)) + + return nil + }) + }) + }) + + Context("when there is no name available for the host-side", func() { + BeforeEach(func() { + //adding different interface to container ns + containerVethName += "0" + }) + It("returns useful error", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + _, _, err := ip.SetupVeth(containerVethName, mtu, hostNetNS) + Expect(err.Error()).To(Equal("failed to move veth to host netns: file exists")) + + return nil + }) + }) + }) + + Context("when there is no name conflict for the host or container interfaces", func() { + BeforeEach(func() { + //adding different interface to container and host ns + containerVethName += "0" + rand.Reader = originalRandReader + }) + It("successfully creates the second veth pair", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + hostVeth, _, err := ip.SetupVeth(containerVethName, mtu, hostNetNS) + Expect(err).NotTo(HaveOccurred()) + hostVethName = hostVeth.Name + return nil + }) + + //verify veths are in different namespaces + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + _, err := netlink.LinkByName(containerVethName) + Expect(err).NotTo(HaveOccurred()) + + return nil + }) + + _ = hostNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + _, err := netlink.LinkByName(hostVethName) + Expect(err).NotTo(HaveOccurred()) + + return nil + }) + }) + + }) + + It("DelLinkByName must delete the veth endpoints", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + // this will delete the host endpoint too + err := ip.DelLinkByName(containerVethName) + Expect(err).NotTo(HaveOccurred()) + + _, err = netlink.LinkByName(containerVethName) + Expect(err).To(HaveOccurred()) + + return nil + }) + + _ = hostNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + _, err := netlink.LinkByName(hostVethName) + Expect(err).To(HaveOccurred()) + + return nil + }) + }) + + It("DelLinkByNameAddr must throw an error for configured interfaces", func() { + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + // this will delete the host endpoint too + addr, err := ip.DelLinkByNameAddr(containerVethName, nl.FAMILY_V4) + Expect(err).To(HaveOccurred()) + + var ipNetNil *net.IPNet + Expect(addr).To(Equal(ipNetNil)) + return nil + }) + }) + + It("SetHWAddrByIP must change the interface hwaddr and be predictable", func() { + + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + var err error + hwaddrBefore := getHwAddr(containerVethName) + + err = ip.SetHWAddrByIP(containerVethName, ip4one, nil) + Expect(err).NotTo(HaveOccurred()) + hwaddrAfter1 := getHwAddr(containerVethName) + + Expect(hwaddrBefore).NotTo(Equal(hwaddrAfter1)) + Expect(hwaddrAfter1).To(Equal(ip4onehwaddr)) + + return nil + }) + }) + + It("SetHWAddrByIP must be injective", func() { + + _ = containerNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + err := ip.SetHWAddrByIP(containerVethName, ip4one, nil) + Expect(err).NotTo(HaveOccurred()) + hwaddrAfter1 := getHwAddr(containerVethName) + + err = ip.SetHWAddrByIP(containerVethName, ip4two, nil) + Expect(err).NotTo(HaveOccurred()) + hwaddrAfter2 := getHwAddr(containerVethName) + + Expect(hwaddrAfter1).NotTo(Equal(hwaddrAfter2)) + return nil + }) + }) +}) diff --git a/pkg/ip/route.go b/pkg/ip/route.go new file mode 100644 index 00000000..1325a47a --- /dev/null +++ b/pkg/ip/route.go @@ -0,0 +1,27 @@ +// Copyright 2015 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 ip + +import ( + "net" + + "github.com/vishvananda/netlink" +) + +// AddDefaultRoute sets the default route on the given gateway. +func AddDefaultRoute(gw net.IP, dev netlink.Link) error { + _, defNet, _ := net.ParseCIDR("0.0.0.0/0") + return AddRoute(defNet, gw, dev) +} diff --git a/pkg/ip/route_linux.go b/pkg/ip/route_linux.go new file mode 100644 index 00000000..8b11807d --- /dev/null +++ b/pkg/ip/route_linux.go @@ -0,0 +1,41 @@ +// Copyright 2015-2017 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 ip + +import ( + "net" + + "github.com/vishvananda/netlink" +) + +// AddRoute adds a universally-scoped route to a device. +func AddRoute(ipn *net.IPNet, gw net.IP, dev netlink.Link) error { + return netlink.RouteAdd(&netlink.Route{ + LinkIndex: dev.Attrs().Index, + Scope: netlink.SCOPE_UNIVERSE, + Dst: ipn, + Gw: gw, + }) +} + +// AddHostRoute adds a host-scoped route to a device. +func AddHostRoute(ipn *net.IPNet, gw net.IP, dev netlink.Link) error { + return netlink.RouteAdd(&netlink.Route{ + LinkIndex: dev.Attrs().Index, + Scope: netlink.SCOPE_HOST, + Dst: ipn, + Gw: gw, + }) +} diff --git a/pkg/ip/route_unspecified.go b/pkg/ip/route_unspecified.go new file mode 100644 index 00000000..7e79fdef --- /dev/null +++ b/pkg/ip/route_unspecified.go @@ -0,0 +1,34 @@ +// Copyright 2015-2017 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. + +// +build !linux + +package ip + +import ( + "net" + + "github.com/containernetworking/cni/pkg/types" + "github.com/vishvananda/netlink" +) + +// AddRoute adds a universally-scoped route to a device. +func AddRoute(ipn *net.IPNet, gw net.IP, dev netlink.Link) error { + return types.NotImplementedError +} + +// AddHostRoute adds a host-scoped route to a device. +func AddHostRoute(ipn *net.IPNet, gw net.IP, dev netlink.Link) error { + return types.NotImplementedError +} diff --git a/pkg/ipam/ipam.go b/pkg/ipam/ipam.go new file mode 100644 index 00000000..b76780f0 --- /dev/null +++ b/pkg/ipam/ipam.go @@ -0,0 +1,93 @@ +// Copyright 2015 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 ipam + +import ( + "fmt" + "net" + "os" + + "github.com/containernetworking/cni/pkg/invoke" + "github.com/containernetworking/cni/pkg/ip" + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/current" + + "github.com/vishvananda/netlink" +) + +func ExecAdd(plugin string, netconf []byte) (types.Result, error) { + return invoke.DelegateAdd(plugin, netconf) +} + +func ExecDel(plugin string, netconf []byte) error { + return invoke.DelegateDel(plugin, netconf) +} + +// ConfigureIface takes the result of IPAM plugin and +// applies to the ifName interface +func ConfigureIface(ifName string, res *current.Result) error { + if len(res.Interfaces) == 0 { + return fmt.Errorf("no interfaces to configure") + } + + link, err := netlink.LinkByName(ifName) + if err != nil { + return fmt.Errorf("failed to lookup %q: %v", ifName, err) + } + + if err := netlink.LinkSetUp(link); err != nil { + return fmt.Errorf("failed to set %q UP: %v", ifName, err) + } + + var v4gw, v6gw net.IP + for _, ipc := range res.IPs { + if int(ipc.Interface) >= len(res.Interfaces) || res.Interfaces[ipc.Interface].Name != ifName { + // IP address is for a different interface + return fmt.Errorf("failed to add IP addr %v to %q: invalid interface index", ipc, ifName) + } + + addr := &netlink.Addr{IPNet: &ipc.Address, Label: ""} + if err = netlink.AddrAdd(link, addr); err != nil { + return fmt.Errorf("failed to add IP addr %v to %q: %v", ipc, ifName, err) + } + + gwIsV4 := ipc.Gateway.To4() != nil + if gwIsV4 && v4gw == nil { + v4gw = ipc.Gateway + } else if !gwIsV4 && v6gw == nil { + v6gw = ipc.Gateway + } + } + + for _, r := range res.Routes { + routeIsV4 := r.Dst.IP.To4() != nil + gw := r.GW + if gw == nil { + if routeIsV4 && v4gw != nil { + gw = v4gw + } else if !routeIsV4 && v6gw != nil { + gw = v6gw + } + } + if err = ip.AddRoute(&r.Dst, gw, link); err != nil { + // we skip over duplicate routes as we assume the first one wins + if !os.IsExist(err) { + return fmt.Errorf("failed to add route '%v via %v dev %v': %v", r.Dst, gw, ifName, err) + } + } + } + + return nil +} diff --git a/pkg/ipam/ipam_suite_test.go b/pkg/ipam/ipam_suite_test.go new file mode 100644 index 00000000..e80c8675 --- /dev/null +++ b/pkg/ipam/ipam_suite_test.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 ipam_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestIpam(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Ipam Suite") +} diff --git a/pkg/ipam/ipam_test.go b/pkg/ipam/ipam_test.go new file mode 100644 index 00000000..2d27825d --- /dev/null +++ b/pkg/ipam/ipam_test.go @@ -0,0 +1,258 @@ +// Copyright 2015 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 ipam + +import ( + "net" + "syscall" + + "github.com/containernetworking/cni/pkg/ns" + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/current" + + "github.com/vishvananda/netlink" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +const LINK_NAME = "eth0" + +func ipNetEqual(a, b *net.IPNet) bool { + aPrefix, aBits := a.Mask.Size() + bPrefix, bBits := b.Mask.Size() + if aPrefix != bPrefix || aBits != bBits { + return false + } + return a.IP.Equal(b.IP) +} + +var _ = Describe("IPAM Operations", func() { + var originalNS ns.NetNS + var ipv4, ipv6, routev4, routev6 *net.IPNet + var ipgw4, ipgw6, routegwv4, routegwv6 net.IP + var result *current.Result + + BeforeEach(func() { + // Create a new NetNS so we don't modify the host + var err error + originalNS, err = ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + err = originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + // Add master + err = netlink.LinkAdd(&netlink.Dummy{ + LinkAttrs: netlink.LinkAttrs{ + Name: LINK_NAME, + }, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = netlink.LinkByName(LINK_NAME) + Expect(err).NotTo(HaveOccurred()) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + ipv4, err = types.ParseCIDR("1.2.3.30/24") + Expect(err).NotTo(HaveOccurred()) + Expect(ipv4).NotTo(BeNil()) + + _, routev4, err = net.ParseCIDR("15.5.6.8/24") + Expect(err).NotTo(HaveOccurred()) + Expect(routev4).NotTo(BeNil()) + routegwv4 = net.ParseIP("1.2.3.5") + Expect(routegwv4).NotTo(BeNil()) + + ipgw4 = net.ParseIP("1.2.3.1") + Expect(ipgw4).NotTo(BeNil()) + + ipv6, err = types.ParseCIDR("abcd:1234:ffff::cdde/64") + Expect(err).NotTo(HaveOccurred()) + Expect(ipv6).NotTo(BeNil()) + + _, routev6, err = net.ParseCIDR("1111:dddd::aaaa/80") + Expect(err).NotTo(HaveOccurred()) + Expect(routev6).NotTo(BeNil()) + routegwv6 = net.ParseIP("abcd:1234:ffff::10") + Expect(routegwv6).NotTo(BeNil()) + + ipgw6 = net.ParseIP("abcd:1234:ffff::1") + Expect(ipgw6).NotTo(BeNil()) + + result = ¤t.Result{ + Interfaces: []*current.Interface{ + { + Name: "eth0", + Mac: "00:11:22:33:44:55", + Sandbox: "/proc/3553/ns/net", + }, + { + Name: "fake0", + Mac: "00:33:44:55:66:77", + Sandbox: "/proc/1234/ns/net", + }, + }, + IPs: []*current.IPConfig{ + { + Version: "4", + Interface: 0, + Address: *ipv4, + Gateway: ipgw4, + }, + { + Version: "6", + Interface: 0, + Address: *ipv6, + Gateway: ipgw6, + }, + }, + Routes: []*types.Route{ + {Dst: *routev4, GW: routegwv4}, + {Dst: *routev6, GW: routegwv6}, + }, + } + }) + + AfterEach(func() { + Expect(originalNS.Close()).To(Succeed()) + }) + + It("configures a link with addresses and routes", func() { + err := originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + err := ConfigureIface(LINK_NAME, result) + Expect(err).NotTo(HaveOccurred()) + + link, err := netlink.LinkByName(LINK_NAME) + Expect(err).NotTo(HaveOccurred()) + Expect(link.Attrs().Name).To(Equal(LINK_NAME)) + + v4addrs, err := netlink.AddrList(link, syscall.AF_INET) + Expect(err).NotTo(HaveOccurred()) + Expect(len(v4addrs)).To(Equal(1)) + Expect(ipNetEqual(v4addrs[0].IPNet, ipv4)).To(Equal(true)) + + v6addrs, err := netlink.AddrList(link, syscall.AF_INET6) + Expect(err).NotTo(HaveOccurred()) + Expect(len(v6addrs)).To(Equal(2)) + + var found bool + for _, a := range v6addrs { + if ipNetEqual(a.IPNet, ipv6) { + found = true + break + } + } + Expect(found).To(Equal(true)) + + // Ensure the v4 route, v6 route, and subnet route + routes, err := netlink.RouteList(link, 0) + Expect(err).NotTo(HaveOccurred()) + + var v4found, v6found bool + for _, route := range routes { + isv4 := route.Dst.IP.To4() != nil + if isv4 && ipNetEqual(route.Dst, routev4) && route.Gw.Equal(routegwv4) { + v4found = true + } + if !isv4 && ipNetEqual(route.Dst, routev6) && route.Gw.Equal(routegwv6) { + v6found = true + } + + if v4found && v6found { + break + } + } + Expect(v4found).To(Equal(true)) + Expect(v6found).To(Equal(true)) + + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("configures a link with routes using address gateways", func() { + result.Routes[0].GW = nil + result.Routes[1].GW = nil + err := originalNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + err := ConfigureIface(LINK_NAME, result) + Expect(err).NotTo(HaveOccurred()) + + link, err := netlink.LinkByName(LINK_NAME) + Expect(err).NotTo(HaveOccurred()) + Expect(link.Attrs().Name).To(Equal(LINK_NAME)) + + // Ensure the v4 route, v6 route, and subnet route + routes, err := netlink.RouteList(link, 0) + Expect(err).NotTo(HaveOccurred()) + + var v4found, v6found bool + for _, route := range routes { + isv4 := route.Dst.IP.To4() != nil + if isv4 && ipNetEqual(route.Dst, routev4) && route.Gw.Equal(ipgw4) { + v4found = true + } + if !isv4 && ipNetEqual(route.Dst, routev6) && route.Gw.Equal(ipgw6) { + v6found = true + } + + if v4found && v6found { + break + } + } + Expect(v4found).To(Equal(true)) + Expect(v6found).To(Equal(true)) + + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns an error when the interface index doesn't match the link name", func() { + result.IPs[0].Interface = 1 + err := originalNS.Do(func(ns.NetNS) error { + return ConfigureIface(LINK_NAME, result) + }) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when the interface index is too big", func() { + result.IPs[0].Interface = 2 + err := originalNS.Do(func(ns.NetNS) error { + return ConfigureIface(LINK_NAME, result) + }) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when there are no interfaces to configure", func() { + result.Interfaces = []*current.Interface{} + err := originalNS.Do(func(ns.NetNS) error { + return ConfigureIface(LINK_NAME, result) + }) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when configuring the wrong interface", func() { + err := originalNS.Do(func(ns.NetNS) error { + return ConfigureIface("asdfasdf", result) + }) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/pkg/ns/README.md b/pkg/ns/README.md new file mode 100644 index 00000000..99aed9c8 --- /dev/null +++ b/pkg/ns/README.md @@ -0,0 +1,34 @@ +### Namespaces, Threads, and Go +On Linux each OS thread can have a different network namespace. Go's thread scheduling model switches goroutines between OS threads based on OS thread load and whether the goroutine would block other goroutines. This can result in a goroutine switching network namespaces without notice and lead to errors in your code. + +### Namespace Switching +Switching namespaces with the `ns.Set()` method is not recommended without additional strategies to prevent unexpected namespace changes when your goroutines switch OS threads. + +Go provides the `runtime.LockOSThread()` function to ensure a specific goroutine executes on its current OS thread and prevents any other goroutine from running in that thread until the locked one exits. Careful usage of `LockOSThread()` and goroutines can provide good control over which network namespace a given goroutine executes in. + +For example, you cannot rely on the `ns.Set()` namespace being the current namespace after the `Set()` call unless you do two things. First, the goroutine calling `Set()` must have previously called `LockOSThread()`. Second, you must ensure `runtime.UnlockOSThread()` is not called somewhere in-between. You also cannot rely on the initial network namespace remaining the current network namespace if any other code in your program switches namespaces, unless you have already called `LockOSThread()` in that goroutine. Note that `LockOSThread()` prevents the Go scheduler from optimally scheduling goroutines for best performance, so `LockOSThread()` should only be used in small, isolated goroutines that release the lock quickly. + +### Do() The Recommended Thing +The `ns.Do()` method provides control over network namespaces for you by implementing these strategies. All code dependent on a particular network namespace (including the root namespace) should be wrapped in the `ns.Do()` method to ensure the correct namespace is selected for the duration of your code. For example: + +```go +targetNs, err := ns.NewNS() +if err != nil { + return err +} +err = targetNs.Do(func(hostNs ns.NetNS) error { + dummy := &netlink.Dummy{ + LinkAttrs: netlink.LinkAttrs{ + Name: "dummy0", + }, + } + return netlink.LinkAdd(dummy) +}) +``` + +Note this requirement to wrap every network call is very onerous - any libraries you call might call out to network services such as DNS, and all such calls need to be protected after you call `ns.Do()`. The CNI plugins all exit very soon after calling `ns.Do()` which helps to minimize the problem. + +### Further Reading + - https://github.com/golang/go/wiki/LockOSThread + - http://morsmachine.dk/go-scheduler + - https://github.com/containernetworking/cni/issues/262 diff --git a/pkg/ns/ns.go b/pkg/ns/ns.go new file mode 100644 index 00000000..c212f489 --- /dev/null +++ b/pkg/ns/ns.go @@ -0,0 +1,178 @@ +// Copyright 2015 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 ns + +import ( + "fmt" + "os" + "runtime" + "sync" + "syscall" +) + +type NetNS interface { + // Executes the passed closure in this object's network namespace, + // attempting to restore the original namespace before returning. + // However, since each OS thread can have a different network namespace, + // and Go's thread scheduling is highly variable, callers cannot + // guarantee any specific namespace is set unless operations that + // require that namespace are wrapped with Do(). Also, no code called + // from Do() should call runtime.UnlockOSThread(), or the risk + // of executing code in an incorrect namespace will be greater. See + // https://github.com/golang/go/wiki/LockOSThread for further details. + Do(toRun func(NetNS) error) error + + // Sets the current network namespace to this object's network namespace. + // Note that since Go's thread scheduling is highly variable, callers + // cannot guarantee the requested namespace will be the current namespace + // after this function is called; to ensure this wrap operations that + // require the namespace with Do() instead. + Set() error + + // Returns the filesystem path representing this object's network namespace + Path() string + + // Returns a file descriptor representing this object's network namespace + Fd() uintptr + + // Cleans up this instance of the network namespace; if this instance + // is the last user the namespace will be destroyed + Close() error +} + +type netNS struct { + file *os.File + mounted bool + closed bool +} + +// netNS implements the NetNS interface +var _ NetNS = &netNS{} + +const ( + // https://github.com/torvalds/linux/blob/master/include/uapi/linux/magic.h + NSFS_MAGIC = 0x6e736673 + PROCFS_MAGIC = 0x9fa0 +) + +type NSPathNotExistErr struct{ msg string } + +func (e NSPathNotExistErr) Error() string { return e.msg } + +type NSPathNotNSErr struct{ msg string } + +func (e NSPathNotNSErr) Error() string { return e.msg } + +func IsNSorErr(nspath string) error { + stat := syscall.Statfs_t{} + if err := syscall.Statfs(nspath, &stat); err != nil { + if os.IsNotExist(err) { + err = NSPathNotExistErr{msg: fmt.Sprintf("failed to Statfs %q: %v", nspath, err)} + } else { + err = fmt.Errorf("failed to Statfs %q: %v", nspath, err) + } + return err + } + + switch stat.Type { + case PROCFS_MAGIC, NSFS_MAGIC: + return nil + default: + return NSPathNotNSErr{msg: fmt.Sprintf("unknown FS magic on %q: %x", nspath, stat.Type)} + } +} + +// Returns an object representing the namespace referred to by @path +func GetNS(nspath string) (NetNS, error) { + err := IsNSorErr(nspath) + if err != nil { + return nil, err + } + + fd, err := os.Open(nspath) + if err != nil { + return nil, err + } + + return &netNS{file: fd}, nil +} + +func (ns *netNS) Path() string { + return ns.file.Name() +} + +func (ns *netNS) Fd() uintptr { + return ns.file.Fd() +} + +func (ns *netNS) errorIfClosed() error { + if ns.closed { + return fmt.Errorf("%q has already been closed", ns.file.Name()) + } + return nil +} + +func (ns *netNS) Do(toRun func(NetNS) error) error { + if err := ns.errorIfClosed(); err != nil { + return err + } + + containedCall := func(hostNS NetNS) error { + threadNS, err := GetCurrentNS() + if err != nil { + return fmt.Errorf("failed to open current netns: %v", err) + } + defer threadNS.Close() + + // switch to target namespace + if err = ns.Set(); err != nil { + return fmt.Errorf("error switching to ns %v: %v", ns.file.Name(), err) + } + defer threadNS.Set() // switch back + + return toRun(hostNS) + } + + // save a handle to current network namespace + hostNS, err := GetCurrentNS() + if err != nil { + return fmt.Errorf("Failed to open current namespace: %v", err) + } + defer hostNS.Close() + + var wg sync.WaitGroup + wg.Add(1) + + var innerError error + go func() { + defer wg.Done() + runtime.LockOSThread() + innerError = containedCall(hostNS) + }() + wg.Wait() + + return innerError +} + +// WithNetNSPath executes the passed closure under the given network +// namespace, restoring the original namespace afterwards. +func WithNetNSPath(nspath string, toRun func(NetNS) error) error { + ns, err := GetNS(nspath) + if err != nil { + return err + } + defer ns.Close() + return ns.Do(toRun) +} diff --git a/pkg/ns/ns_linux.go b/pkg/ns/ns_linux.go new file mode 100644 index 00000000..c9e1b4f0 --- /dev/null +++ b/pkg/ns/ns_linux.go @@ -0,0 +1,149 @@ +// Copyright 2015-2017 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 ns + +import ( + "crypto/rand" + "fmt" + "os" + "path" + "runtime" + "sync" + + "golang.org/x/sys/unix" +) + +// Returns an object representing the current OS thread's network namespace +func GetCurrentNS() (NetNS, error) { + return GetNS(getCurrentThreadNetNSPath()) +} + +func getCurrentThreadNetNSPath() string { + // /proc/self/ns/net returns the namespace of the main thread, not + // of whatever thread this goroutine is running on. Make sure we + // use the thread's net namespace since the thread is switching around + return fmt.Sprintf("/proc/%d/task/%d/ns/net", os.Getpid(), unix.Gettid()) +} + +// Creates a new persistent network namespace and returns an object +// representing that namespace, without switching to it +func NewNS() (NetNS, error) { + const nsRunDir = "/var/run/netns" + + b := make([]byte, 16) + _, err := rand.Reader.Read(b) + if err != nil { + return nil, fmt.Errorf("failed to generate random netns name: %v", err) + } + + err = os.MkdirAll(nsRunDir, 0755) + if err != nil { + return nil, err + } + + // create an empty file at the mount point + nsName := fmt.Sprintf("cni-%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) + nsPath := path.Join(nsRunDir, nsName) + mountPointFd, err := os.Create(nsPath) + if err != nil { + return nil, err + } + mountPointFd.Close() + + // Ensure the mount point is cleaned up on errors; if the namespace + // was successfully mounted this will have no effect because the file + // is in-use + defer os.RemoveAll(nsPath) + + var wg sync.WaitGroup + wg.Add(1) + + // do namespace work in a dedicated goroutine, so that we can safely + // Lock/Unlock OSThread without upsetting the lock/unlock state of + // the caller of this function + var fd *os.File + go (func() { + defer wg.Done() + runtime.LockOSThread() + + var origNS NetNS + origNS, err = GetNS(getCurrentThreadNetNSPath()) + if err != nil { + return + } + defer origNS.Close() + + // create a new netns on the current thread + err = unix.Unshare(unix.CLONE_NEWNET) + if err != nil { + return + } + defer origNS.Set() + + // bind mount the new netns from the current thread onto the mount point + err = unix.Mount(getCurrentThreadNetNSPath(), nsPath, "none", unix.MS_BIND, "") + if err != nil { + return + } + + fd, err = os.Open(nsPath) + if err != nil { + return + } + })() + wg.Wait() + + if err != nil { + unix.Unmount(nsPath, unix.MNT_DETACH) + return nil, fmt.Errorf("failed to create namespace: %v", err) + } + + return &netNS{file: fd, mounted: true}, nil +} + +func (ns *netNS) Close() error { + if err := ns.errorIfClosed(); err != nil { + return err + } + + if err := ns.file.Close(); err != nil { + return fmt.Errorf("Failed to close %q: %v", ns.file.Name(), err) + } + ns.closed = true + + if ns.mounted { + if err := unix.Unmount(ns.file.Name(), unix.MNT_DETACH); err != nil { + return fmt.Errorf("Failed to unmount namespace %s: %v", ns.file.Name(), err) + } + if err := os.RemoveAll(ns.file.Name()); err != nil { + return fmt.Errorf("Failed to clean up namespace %s: %v", ns.file.Name(), err) + } + ns.mounted = false + } + + return nil +} + +func (ns *netNS) Set() error { + if err := ns.errorIfClosed(); err != nil { + return err + } + + if _, _, err := unix.Syscall(unix.SYS_SETNS, ns.Fd(), uintptr(unix.CLONE_NEWNET), 0); err != 0 { + return fmt.Errorf("Error switching to ns %v: %v", ns.file.Name(), err) + } + + return nil +} diff --git a/pkg/ns/ns_suite_test.go b/pkg/ns/ns_suite_test.go new file mode 100644 index 00000000..e2adaa4e --- /dev/null +++ b/pkg/ns/ns_suite_test.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 ns_test + +import ( + "math/rand" + "runtime" + + . "github.com/onsi/ginkgo" + "github.com/onsi/ginkgo/config" + . "github.com/onsi/gomega" + + "testing" +) + +func TestNs(t *testing.T) { + rand.Seed(config.GinkgoConfig.RandomSeed) + runtime.LockOSThread() + + RegisterFailHandler(Fail) + RunSpecs(t, "pkg/ns Suite") +} diff --git a/pkg/ns/ns_test.go b/pkg/ns/ns_test.go new file mode 100644 index 00000000..44ed2728 --- /dev/null +++ b/pkg/ns/ns_test.go @@ -0,0 +1,252 @@ +// 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 ns_test + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "github.com/containernetworking/cni/pkg/ns" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "golang.org/x/sys/unix" +) + +func getInodeCurNetNS() (uint64, error) { + curNS, err := ns.GetCurrentNS() + if err != nil { + return 0, err + } + defer curNS.Close() + return getInodeNS(curNS) +} + +func getInodeNS(netns ns.NetNS) (uint64, error) { + return getInodeFd(int(netns.Fd())) +} + +func getInode(path string) (uint64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + return getInodeFd(int(file.Fd())) +} + +func getInodeFd(fd int) (uint64, error) { + stat := &unix.Stat_t{} + err := unix.Fstat(fd, stat) + return stat.Ino, err +} + +var _ = Describe("Linux namespace operations", func() { + Describe("WithNetNS", func() { + var ( + originalNetNS ns.NetNS + targetNetNS ns.NetNS + ) + + BeforeEach(func() { + var err error + + originalNetNS, err = ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + targetNetNS, err = ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(targetNetNS.Close()).To(Succeed()) + Expect(originalNetNS.Close()).To(Succeed()) + }) + + It("executes the callback within the target network namespace", func() { + expectedInode, err := getInodeNS(targetNetNS) + Expect(err).NotTo(HaveOccurred()) + + err = targetNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + actualInode, err := getInodeCurNetNS() + Expect(err).NotTo(HaveOccurred()) + Expect(actualInode).To(Equal(expectedInode)) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("provides the original namespace as the argument to the callback", func() { + // Ensure we start in originalNetNS + err := originalNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + origNSInode, err := getInodeNS(originalNetNS) + Expect(err).NotTo(HaveOccurred()) + + err = targetNetNS.Do(func(hostNS ns.NetNS) error { + defer GinkgoRecover() + + hostNSInode, err := getInodeNS(hostNS) + Expect(err).NotTo(HaveOccurred()) + Expect(hostNSInode).To(Equal(origNSInode)) + return nil + }) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + Context("when the callback returns an error", func() { + It("restores the calling thread to the original namespace before returning", func() { + err := originalNetNS.Do(func(ns.NetNS) error { + defer GinkgoRecover() + + preTestInode, err := getInodeCurNetNS() + Expect(err).NotTo(HaveOccurred()) + + _ = targetNetNS.Do(func(ns.NetNS) error { + return errors.New("potato") + }) + + postTestInode, err := getInodeCurNetNS() + Expect(err).NotTo(HaveOccurred()) + Expect(postTestInode).To(Equal(preTestInode)) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns the error from the callback", func() { + err := targetNetNS.Do(func(ns.NetNS) error { + return errors.New("potato") + }) + Expect(err).To(MatchError("potato")) + }) + }) + + Describe("validating inode mapping to namespaces", func() { + It("checks that different namespaces have different inodes", func() { + origNSInode, err := getInodeNS(originalNetNS) + Expect(err).NotTo(HaveOccurred()) + + testNsInode, err := getInodeNS(targetNetNS) + Expect(err).NotTo(HaveOccurred()) + + Expect(testNsInode).NotTo(Equal(0)) + Expect(testNsInode).NotTo(Equal(origNSInode)) + }) + + It("should not leak a closed netns onto any threads in the process", func() { + By("creating a new netns") + createdNetNS, err := ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + By("discovering the inode of the created netns") + createdNetNSInode, err := getInodeNS(createdNetNS) + Expect(err).NotTo(HaveOccurred()) + createdNetNS.Close() + + By("comparing against the netns inode of every thread in the process") + for _, netnsPath := range allNetNSInCurrentProcess() { + netnsInode, err := getInode(netnsPath) + Expect(err).NotTo(HaveOccurred()) + Expect(netnsInode).NotTo(Equal(createdNetNSInode)) + } + }) + + It("fails when the path is not a namespace", func() { + tempFile, err := ioutil.TempFile("", "nstest") + Expect(err).NotTo(HaveOccurred()) + defer tempFile.Close() + + nspath := tempFile.Name() + defer os.Remove(nspath) + + _, err = ns.GetNS(nspath) + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(ns.NSPathNotNSErr{})) + Expect(err).NotTo(BeAssignableToTypeOf(ns.NSPathNotExistErr{})) + }) + }) + + Describe("closing a network namespace", func() { + It("should prevent further operations", func() { + createdNetNS, err := ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + err = createdNetNS.Close() + Expect(err).NotTo(HaveOccurred()) + + err = createdNetNS.Do(func(ns.NetNS) error { return nil }) + Expect(err).To(HaveOccurred()) + + err = createdNetNS.Set() + Expect(err).To(HaveOccurred()) + }) + + It("should only work once", func() { + createdNetNS, err := ns.NewNS() + Expect(err).NotTo(HaveOccurred()) + + err = createdNetNS.Close() + Expect(err).NotTo(HaveOccurred()) + + err = createdNetNS.Close() + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("IsNSorErr", func() { + It("should detect a namespace", func() { + createdNetNS, err := ns.NewNS() + err = ns.IsNSorErr(createdNetNS.Path()) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should refuse other paths", func() { + tempFile, err := ioutil.TempFile("", "nstest") + Expect(err).NotTo(HaveOccurred()) + defer tempFile.Close() + + nspath := tempFile.Name() + defer os.Remove(nspath) + + err = ns.IsNSorErr(nspath) + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(ns.NSPathNotNSErr{})) + Expect(err).NotTo(BeAssignableToTypeOf(ns.NSPathNotExistErr{})) + }) + + It("should error on non-existing paths", func() { + err := ns.IsNSorErr("/tmp/IDoNotExist") + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(ns.NSPathNotExistErr{})) + Expect(err).NotTo(BeAssignableToTypeOf(ns.NSPathNotNSErr{})) + }) + }) +}) + +func allNetNSInCurrentProcess() []string { + pid := unix.Getpid() + paths, err := filepath.Glob(fmt.Sprintf("/proc/%d/task/*/ns/net", pid)) + Expect(err).NotTo(HaveOccurred()) + return paths +} diff --git a/pkg/ns/ns_unspecified.go b/pkg/ns/ns_unspecified.go new file mode 100644 index 00000000..41b44686 --- /dev/null +++ b/pkg/ns/ns_unspecified.go @@ -0,0 +1,36 @@ +// Copyright 2015-2017 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. + +// +build !linux + +package ns + +import "github.com/containernetworking/cni/pkg/types" + +// Returns an object representing the current OS thread's network namespace +func GetCurrentNS() (NetNS, error) { + return nil, types.NotImplementedError +} + +func NewNS() (NetNS, error) { + return nil, types.NotImplementedError +} + +func (ns *netNS) Close() error { + return types.NotImplementedError +} + +func (ns *netNS) Set() error { + return types.NotImplementedError +} diff --git a/pkg/skel/skel.go b/pkg/skel/skel.go new file mode 100644 index 00000000..8644c25e --- /dev/null +++ b/pkg/skel/skel.go @@ -0,0 +1,228 @@ +// Copyright 2014-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 skel provides skeleton code for a CNI plugin. +// In particular, it implements argument parsing and validation. +package skel + +import ( + "fmt" + "io" + "io/ioutil" + "log" + "os" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/version" +) + +// CmdArgs captures all the arguments passed in to the plugin +// via both env vars and stdin +type CmdArgs struct { + ContainerID string + Netns string + IfName string + Args string + Path string + StdinData []byte +} + +type dispatcher struct { + Getenv func(string) string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + + ConfVersionDecoder version.ConfigDecoder + VersionReconciler version.Reconciler +} + +type reqForCmdEntry map[string]bool + +func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, error) { + var cmd, contID, netns, ifName, args, path string + + vars := []struct { + name string + val *string + reqForCmd reqForCmdEntry + }{ + { + "CNI_COMMAND", + &cmd, + reqForCmdEntry{ + "ADD": true, + "DEL": true, + }, + }, + { + "CNI_CONTAINERID", + &contID, + reqForCmdEntry{ + "ADD": false, + "DEL": false, + }, + }, + { + "CNI_NETNS", + &netns, + reqForCmdEntry{ + "ADD": true, + "DEL": false, + }, + }, + { + "CNI_IFNAME", + &ifName, + reqForCmdEntry{ + "ADD": true, + "DEL": true, + }, + }, + { + "CNI_ARGS", + &args, + reqForCmdEntry{ + "ADD": false, + "DEL": false, + }, + }, + { + "CNI_PATH", + &path, + reqForCmdEntry{ + "ADD": true, + "DEL": true, + }, + }, + } + + argsMissing := false + for _, v := range vars { + *v.val = t.Getenv(v.name) + if *v.val == "" { + if v.reqForCmd[cmd] || v.name == "CNI_COMMAND" { + fmt.Fprintf(t.Stderr, "%v env variable missing\n", v.name) + argsMissing = true + } + } + } + + if argsMissing { + return "", nil, fmt.Errorf("required env variables missing") + } + + stdinData, err := ioutil.ReadAll(t.Stdin) + if err != nil { + return "", nil, fmt.Errorf("error reading from stdin: %v", err) + } + + cmdArgs := &CmdArgs{ + ContainerID: contID, + Netns: netns, + IfName: ifName, + Args: args, + Path: path, + StdinData: stdinData, + } + return cmd, cmdArgs, nil +} + +func createTypedError(f string, args ...interface{}) *types.Error { + return &types.Error{ + Code: 100, + Msg: fmt.Sprintf(f, args...), + } +} + +func (t *dispatcher) checkVersionAndCall(cmdArgs *CmdArgs, pluginVersionInfo version.PluginInfo, toCall func(*CmdArgs) error) error { + configVersion, err := t.ConfVersionDecoder.Decode(cmdArgs.StdinData) + if err != nil { + return err + } + verErr := t.VersionReconciler.Check(configVersion, pluginVersionInfo) + if verErr != nil { + return &types.Error{ + Code: types.ErrIncompatibleCNIVersion, + Msg: "incompatible CNI versions", + Details: verErr.Details(), + } + } + return toCall(cmdArgs) +} + +func (t *dispatcher) pluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) *types.Error { + cmd, cmdArgs, err := t.getCmdArgsFromEnv() + if err != nil { + return createTypedError(err.Error()) + } + + switch cmd { + case "ADD": + err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdAdd) + case "DEL": + err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdDel) + case "VERSION": + err = versionInfo.Encode(t.Stdout) + default: + return createTypedError("unknown CNI_COMMAND: %v", cmd) + } + + if err != nil { + if e, ok := err.(*types.Error); ok { + // don't wrap Error in Error + return e + } + return createTypedError(err.Error()) + } + return nil +} + +// PluginMainWithError is the core "main" for a plugin. It accepts +// callback functions for add and del CNI commands and returns an error. +// +// The caller must also specify what CNI spec versions the plugin supports. +// +// It is the responsibility of the caller to check for non-nil error return. +// +// For a plugin to comply with the CNI spec, it must print any error to stdout +// as JSON and then exit with nonzero status code. +// +// To let this package automatically handle errors and call os.Exit(1) for you, +// use PluginMain() instead. +func PluginMainWithError(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) *types.Error { + return (&dispatcher{ + Getenv: os.Getenv, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + }).pluginMain(cmdAdd, cmdDel, versionInfo) +} + +// PluginMain is the core "main" for a plugin which includes automatic error handling. +// +// The caller must also specify what CNI spec versions the plugin supports. +// +// When an error occurs in either cmdAdd or cmdDel, PluginMain will print the error +// as JSON to stdout and call os.Exit(1). +// +// To have more control over error handling, use PluginMainWithError() instead. +func PluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) { + if e := PluginMainWithError(cmdAdd, cmdDel, versionInfo); e != nil { + if err := e.Print(); err != nil { + log.Print("Error writing error JSON to stdout: ", err) + } + os.Exit(1) + } +} diff --git a/pkg/skel/skel_suite_test.go b/pkg/skel/skel_suite_test.go new file mode 100644 index 00000000..df5a8e77 --- /dev/null +++ b/pkg/skel/skel_suite_test.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 skel + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestSkel(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Skel Suite") +} diff --git a/pkg/skel/skel_test.go b/pkg/skel/skel_test.go new file mode 100644 index 00000000..ad293084 --- /dev/null +++ b/pkg/skel/skel_test.go @@ -0,0 +1,346 @@ +// 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 skel + +import ( + "bytes" + "errors" + "strings" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/version" + + "github.com/containernetworking/cni/pkg/testutils" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +type fakeCmd struct { + CallCount int + Returns struct { + Error error + } + Received struct { + CmdArgs *CmdArgs + } +} + +func (c *fakeCmd) Func(args *CmdArgs) error { + c.CallCount++ + c.Received.CmdArgs = args + return c.Returns.Error +} + +var _ = Describe("dispatching to the correct callback", func() { + var ( + environment map[string]string + stdinData string + stdout, stderr *bytes.Buffer + cmdAdd, cmdDel *fakeCmd + dispatch *dispatcher + expectedCmdArgs *CmdArgs + versionInfo version.PluginInfo + ) + + BeforeEach(func() { + environment = map[string]string{ + "CNI_COMMAND": "ADD", + "CNI_CONTAINERID": "some-container-id", + "CNI_NETNS": "/some/netns/path", + "CNI_IFNAME": "eth0", + "CNI_ARGS": "some;extra;args", + "CNI_PATH": "/some/cni/path", + } + + stdinData = `{ "some": "config", "cniVersion": "9.8.7" }` + stdout = &bytes.Buffer{} + stderr = &bytes.Buffer{} + versionInfo = version.PluginSupports("9.8.7") + dispatch = &dispatcher{ + Getenv: func(key string) string { return environment[key] }, + Stdin: strings.NewReader(stdinData), + Stdout: stdout, + Stderr: stderr, + } + cmdAdd = &fakeCmd{} + cmdDel = &fakeCmd{} + expectedCmdArgs = &CmdArgs{ + ContainerID: "some-container-id", + Netns: "/some/netns/path", + IfName: "eth0", + Args: "some;extra;args", + Path: "/some/cni/path", + StdinData: []byte(stdinData), + } + }) + + var envVarChecker = func(envVar string, isRequired bool) { + delete(environment, envVar) + + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + if isRequired { + Expect(err).To(Equal(&types.Error{ + Code: 100, + Msg: "required env variables missing", + })) + Expect(stderr.String()).To(ContainSubstring(envVar + " env variable missing\n")) + } else { + Expect(err).NotTo(HaveOccurred()) + } + } + + Context("when the CNI_COMMAND is ADD", func() { + It("extracts env vars and stdin data and calls cmdAdd", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdAdd.CallCount).To(Equal(1)) + Expect(cmdDel.CallCount).To(Equal(0)) + Expect(cmdAdd.Received.CmdArgs).To(Equal(expectedCmdArgs)) + }) + + It("does not call cmdDel", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdDel.CallCount).To(Equal(0)) + }) + + DescribeTable("required / optional env vars", envVarChecker, + Entry("command", "CNI_COMMAND", true), + Entry("container id", "CNI_CONTAINERID", false), + Entry("net ns", "CNI_NETNS", true), + Entry("if name", "CNI_IFNAME", true), + Entry("args", "CNI_ARGS", false), + Entry("path", "CNI_PATH", true), + ) + + Context("when multiple required env vars are missing", func() { + BeforeEach(func() { + delete(environment, "CNI_NETNS") + delete(environment, "CNI_IFNAME") + delete(environment, "CNI_PATH") + }) + + It("reports that all of them are missing, not just the first", func() { + Expect(dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo)).NotTo(Succeed()) + log := stderr.String() + Expect(log).To(ContainSubstring("CNI_NETNS env variable missing\n")) + Expect(log).To(ContainSubstring("CNI_IFNAME env variable missing\n")) + Expect(log).To(ContainSubstring("CNI_PATH env variable missing\n")) + + }) + }) + + Context("when the stdin data is missing the required cniVersion config", func() { + BeforeEach(func() { + dispatch.Stdin = strings.NewReader(`{ "some": "config" }`) + }) + + Context("when the plugin supports version 0.1.0", func() { + BeforeEach(func() { + versionInfo = version.PluginSupports("0.1.0") + expectedCmdArgs.StdinData = []byte(`{ "some": "config" }`) + }) + + It("infers the config is 0.1.0 and calls the cmdAdd callback", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + Expect(err).NotTo(HaveOccurred()) + + Expect(cmdAdd.CallCount).To(Equal(1)) + Expect(cmdAdd.Received.CmdArgs).To(Equal(expectedCmdArgs)) + }) + }) + + Context("when the plugin does not support 0.1.0", func() { + BeforeEach(func() { + versionInfo = version.PluginSupports("4.3.2") + }) + + It("immediately returns a useful error", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + Expect(err.Code).To(Equal(types.ErrIncompatibleCNIVersion)) // see https://github.com/containernetworking/cni/blob/master/SPEC.md#well-known-error-codes + Expect(err.Msg).To(Equal("incompatible CNI versions")) + Expect(err.Details).To(Equal(`config is "0.1.0", plugin supports ["4.3.2"]`)) + }) + + It("does not call either callback", func() { + dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + Expect(cmdAdd.CallCount).To(Equal(0)) + Expect(cmdDel.CallCount).To(Equal(0)) + }) + }) + }) + }) + + Context("when the CNI_COMMAND is DEL", func() { + BeforeEach(func() { + environment["CNI_COMMAND"] = "DEL" + }) + + It("calls cmdDel with the env vars and stdin data", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdDel.CallCount).To(Equal(1)) + Expect(cmdDel.Received.CmdArgs).To(Equal(expectedCmdArgs)) + }) + + It("does not call cmdAdd", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdAdd.CallCount).To(Equal(0)) + }) + + DescribeTable("required / optional env vars", envVarChecker, + Entry("command", "CNI_COMMAND", true), + Entry("container id", "CNI_CONTAINERID", false), + Entry("net ns", "CNI_NETNS", false), + Entry("if name", "CNI_IFNAME", true), + Entry("args", "CNI_ARGS", false), + Entry("path", "CNI_PATH", true), + ) + }) + + Context("when the CNI_COMMAND is VERSION", func() { + BeforeEach(func() { + environment["CNI_COMMAND"] = "VERSION" + }) + + It("prints the version to stdout", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(MatchJSON(`{ + "cniVersion": "0.3.1", + "supportedVersions": ["9.8.7"] + }`)) + }) + + It("does not call cmdAdd or cmdDel", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdAdd.CallCount).To(Equal(0)) + Expect(cmdDel.CallCount).To(Equal(0)) + }) + + DescribeTable("VERSION does not need the usual env vars", envVarChecker, + Entry("command", "CNI_COMMAND", true), + Entry("container id", "CNI_CONTAINERID", false), + Entry("net ns", "CNI_NETNS", false), + Entry("if name", "CNI_IFNAME", false), + Entry("args", "CNI_ARGS", false), + Entry("path", "CNI_PATH", false), + ) + + Context("when the stdin is empty", func() { + BeforeEach(func() { + dispatch.Stdin = strings.NewReader("") + }) + + It("succeeds without error", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(MatchJSON(`{ + "cniVersion": "0.3.1", + "supportedVersions": ["9.8.7"] + }`)) + }) + }) + }) + + Context("when the CNI_COMMAND is unrecognized", func() { + BeforeEach(func() { + environment["CNI_COMMAND"] = "NOPE" + }) + + It("does not call any cmd callback", func() { + dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(cmdAdd.CallCount).To(Equal(0)) + Expect(cmdDel.CallCount).To(Equal(0)) + }) + + It("returns an error", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).To(Equal(&types.Error{ + Code: 100, + Msg: "unknown CNI_COMMAND: NOPE", + })) + }) + }) + + Context("when stdin cannot be read", func() { + BeforeEach(func() { + dispatch.Stdin = &testutils.BadReader{} + }) + + It("does not call any cmd callback", func() { + dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(cmdAdd.CallCount).To(Equal(0)) + Expect(cmdDel.CallCount).To(Equal(0)) + }) + + It("wraps and returns the error", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).To(Equal(&types.Error{ + Code: 100, + Msg: "error reading from stdin: banana", + })) + }) + }) + + Context("when the callback returns an error", func() { + Context("when it is a typed Error", func() { + BeforeEach(func() { + cmdAdd.Returns.Error = &types.Error{ + Code: 1234, + Msg: "insufficient something", + } + }) + + It("returns the error as-is", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).To(Equal(&types.Error{ + Code: 1234, + Msg: "insufficient something", + })) + }) + }) + + Context("when it is an unknown error", func() { + BeforeEach(func() { + cmdAdd.Returns.Error = errors.New("potato") + }) + + It("wraps and returns the error", func() { + err := dispatch.pluginMain(cmdAdd.Func, cmdDel.Func, versionInfo) + + Expect(err).To(Equal(&types.Error{ + Code: 100, + Msg: "potato", + })) + }) + }) + }) +}) diff --git a/pkg/testutils/bad_reader.go b/pkg/testutils/bad_reader.go new file mode 100644 index 00000000..f9d0aded --- /dev/null +++ b/pkg/testutils/bad_reader.go @@ -0,0 +1,33 @@ +// 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 testutils + +import "errors" + +// BadReader is an io.Reader which always errors +type BadReader struct { + Error error +} + +func (r *BadReader) Read(buffer []byte) (int, error) { + if r.Error != nil { + return 0, r.Error + } + return 0, errors.New("banana") +} + +func (r *BadReader) Close() error { + return nil +} diff --git a/pkg/testutils/cmd.go b/pkg/testutils/cmd.go new file mode 100644 index 00000000..a6045b31 --- /dev/null +++ b/pkg/testutils/cmd.go @@ -0,0 +1,85 @@ +// 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 testutils + +import ( + "io/ioutil" + "os" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/version" +) + +func envCleanup() { + os.Unsetenv("CNI_COMMAND") + os.Unsetenv("CNI_PATH") + os.Unsetenv("CNI_NETNS") + os.Unsetenv("CNI_IFNAME") +} + +func CmdAddWithResult(cniNetns, cniIfname string, conf []byte, f func() error) (types.Result, []byte, error) { + os.Setenv("CNI_COMMAND", "ADD") + os.Setenv("CNI_PATH", os.Getenv("PATH")) + os.Setenv("CNI_NETNS", cniNetns) + os.Setenv("CNI_IFNAME", cniIfname) + defer envCleanup() + + // Redirect stdout to capture plugin result + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + return nil, nil, err + } + + os.Stdout = w + err = f() + w.Close() + + var out []byte + if err == nil { + out, err = ioutil.ReadAll(r) + } + os.Stdout = oldStdout + + // Return errors after restoring stdout so Ginkgo will correctly + // emit verbose error information on stdout + if err != nil { + return nil, nil, err + } + + // Plugin must return result in same version as specified in netconf + versionDecoder := &version.ConfigDecoder{} + confVersion, err := versionDecoder.Decode(conf) + if err != nil { + return nil, nil, err + } + + result, err := version.NewResult(confVersion, out) + if err != nil { + return nil, nil, err + } + + return result, out, nil +} + +func CmdDelWithResult(cniNetns, cniIfname string, f func() error) error { + os.Setenv("CNI_COMMAND", "DEL") + os.Setenv("CNI_PATH", os.Getenv("PATH")) + os.Setenv("CNI_NETNS", cniNetns) + os.Setenv("CNI_IFNAME", cniIfname) + defer envCleanup() + + return f() +} diff --git a/pkg/types/020/types.go b/pkg/types/020/types.go new file mode 100644 index 00000000..2833aba7 --- /dev/null +++ b/pkg/types/020/types.go @@ -0,0 +1,135 @@ +// 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 types020 + +import ( + "encoding/json" + "fmt" + "net" + "os" + + "github.com/containernetworking/cni/pkg/types" +) + +const ImplementedSpecVersion string = "0.2.0" + +var SupportedVersions = []string{"", "0.1.0", ImplementedSpecVersion} + +// Compatibility types for CNI version 0.1.0 and 0.2.0 + +func NewResult(data []byte) (types.Result, error) { + result := &Result{} + if err := json.Unmarshal(data, result); err != nil { + return nil, err + } + return result, nil +} + +func GetResult(r types.Result) (*Result, error) { + // We expect version 0.1.0/0.2.0 results + result020, err := r.GetAsVersion(ImplementedSpecVersion) + if err != nil { + return nil, err + } + result, ok := result020.(*Result) + if !ok { + return nil, fmt.Errorf("failed to convert result") + } + return result, nil +} + +// Result is what gets returned from the plugin (via stdout) to the caller +type Result struct { + CNIVersion string `json:"cniVersion,omitempty"` + IP4 *IPConfig `json:"ip4,omitempty"` + IP6 *IPConfig `json:"ip6,omitempty"` + DNS types.DNS `json:"dns,omitempty"` +} + +func (r *Result) Version() string { + return ImplementedSpecVersion +} + +func (r *Result) GetAsVersion(version string) (types.Result, error) { + for _, supportedVersion := range SupportedVersions { + if version == supportedVersion { + r.CNIVersion = version + return r, nil + } + } + return nil, fmt.Errorf("cannot convert version %q to %s", SupportedVersions, version) +} + +func (r *Result) Print() error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + _, err = os.Stdout.Write(data) + return err +} + +// String returns a formatted string in the form of "[IP4: $1,][ IP6: $2,] DNS: $3" where +// $1 represents the receiver's IPv4, $2 represents the receiver's IPv6 and $3 the +// receiver's DNS. If $1 or $2 are nil, they won't be present in the returned string. +func (r *Result) String() string { + var str string + if r.IP4 != nil { + str = fmt.Sprintf("IP4:%+v, ", *r.IP4) + } + if r.IP6 != nil { + str += fmt.Sprintf("IP6:%+v, ", *r.IP6) + } + return fmt.Sprintf("%sDNS:%+v", str, r.DNS) +} + +// IPConfig contains values necessary to configure an interface +type IPConfig struct { + IP net.IPNet + Gateway net.IP + Routes []types.Route +} + +// net.IPNet is not JSON (un)marshallable so this duality is needed +// for our custom IPNet type + +// JSON (un)marshallable types +type ipConfig struct { + IP types.IPNet `json:"ip"` + Gateway net.IP `json:"gateway,omitempty"` + Routes []types.Route `json:"routes,omitempty"` +} + +func (c *IPConfig) MarshalJSON() ([]byte, error) { + ipc := ipConfig{ + IP: types.IPNet(c.IP), + Gateway: c.Gateway, + Routes: c.Routes, + } + + return json.Marshal(ipc) +} + +func (c *IPConfig) UnmarshalJSON(data []byte) error { + ipc := ipConfig{} + if err := json.Unmarshal(data, &ipc); err != nil { + return err + } + + c.IP = net.IPNet(ipc.IP) + c.Gateway = ipc.Gateway + c.Routes = ipc.Routes + return nil +} diff --git a/pkg/types/020/types_suite_test.go b/pkg/types/020/types_suite_test.go new file mode 100644 index 00000000..095d73e2 --- /dev/null +++ b/pkg/types/020/types_suite_test.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 types020_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestTypes010(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "0.1.0/0.2.0 Types Suite") +} diff --git a/pkg/types/020/types_test.go b/pkg/types/020/types_test.go new file mode 100644 index 00000000..4f08ca49 --- /dev/null +++ b/pkg/types/020/types_test.go @@ -0,0 +1,130 @@ +// 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 types020_test + +import ( + "io/ioutil" + "net" + "os" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/020" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Ensures compatibility with the 0.1.0/0.2.0 spec", func() { + It("correctly encodes a 0.1.0/0.2.0 Result", func() { + ipv4, err := types.ParseCIDR("1.2.3.30/24") + Expect(err).NotTo(HaveOccurred()) + Expect(ipv4).NotTo(BeNil()) + + routegwv4, routev4, err := net.ParseCIDR("15.5.6.8/24") + Expect(err).NotTo(HaveOccurred()) + Expect(routev4).NotTo(BeNil()) + Expect(routegwv4).NotTo(BeNil()) + + ipv6, err := types.ParseCIDR("abcd:1234:ffff::cdde/64") + Expect(err).NotTo(HaveOccurred()) + Expect(ipv6).NotTo(BeNil()) + + routegwv6, routev6, err := net.ParseCIDR("1111:dddd::aaaa/80") + Expect(err).NotTo(HaveOccurred()) + Expect(routev6).NotTo(BeNil()) + Expect(routegwv6).NotTo(BeNil()) + + // Set every field of the struct to ensure source compatibility + res := types020.Result{ + CNIVersion: types020.ImplementedSpecVersion, + IP4: &types020.IPConfig{ + IP: *ipv4, + Gateway: net.ParseIP("1.2.3.1"), + Routes: []types.Route{ + {Dst: *routev4, GW: routegwv4}, + }, + }, + IP6: &types020.IPConfig{ + IP: *ipv6, + Gateway: net.ParseIP("abcd:1234:ffff::1"), + Routes: []types.Route{ + {Dst: *routev6, GW: routegwv6}, + }, + }, + DNS: types.DNS{ + Nameservers: []string{"1.2.3.4", "1::cafe"}, + Domain: "acompany.com", + Search: []string{"somedomain.com", "otherdomain.net"}, + Options: []string{"foo", "bar"}, + }, + } + + Expect(res.String()).To(Equal("IP4:{IP:{IP:1.2.3.30 Mask:ffffff00} Gateway:1.2.3.1 Routes:[{Dst:{IP:15.5.6.0 Mask:ffffff00} GW:15.5.6.8}]}, IP6:{IP:{IP:abcd:1234:ffff::cdde Mask:ffffffffffffffff0000000000000000} Gateway:abcd:1234:ffff::1 Routes:[{Dst:{IP:1111:dddd:: Mask:ffffffffffffffffffff000000000000} GW:1111:dddd::aaaa}]}, DNS:{Nameservers:[1.2.3.4 1::cafe] Domain:acompany.com Search:[somedomain.com otherdomain.net] Options:[foo bar]}")) + + // Redirect stdout to capture JSON result + oldStdout := os.Stdout + r, w, err := os.Pipe() + Expect(err).NotTo(HaveOccurred()) + + os.Stdout = w + err = res.Print() + w.Close() + Expect(err).NotTo(HaveOccurred()) + + // parse the result + out, err := ioutil.ReadAll(r) + os.Stdout = oldStdout + Expect(err).NotTo(HaveOccurred()) + + Expect(string(out)).To(Equal(`{ + "cniVersion": "0.2.0", + "ip4": { + "ip": "1.2.3.30/24", + "gateway": "1.2.3.1", + "routes": [ + { + "dst": "15.5.6.0/24", + "gw": "15.5.6.8" + } + ] + }, + "ip6": { + "ip": "abcd:1234:ffff::cdde/64", + "gateway": "abcd:1234:ffff::1", + "routes": [ + { + "dst": "1111:dddd::/80", + "gw": "1111:dddd::aaaa" + } + ] + }, + "dns": { + "nameservers": [ + "1.2.3.4", + "1::cafe" + ], + "domain": "acompany.com", + "search": [ + "somedomain.com", + "otherdomain.net" + ], + "options": [ + "foo", + "bar" + ] + } +}`)) + }) +}) diff --git a/pkg/types/args.go b/pkg/types/args.go new file mode 100644 index 00000000..66dcf9ea --- /dev/null +++ b/pkg/types/args.go @@ -0,0 +1,101 @@ +// Copyright 2015 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 types + +import ( + "encoding" + "fmt" + "reflect" + "strings" +) + +// UnmarshallableBool typedef for builtin bool +// because builtin type's methods can't be declared +type UnmarshallableBool bool + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +// Returns boolean true if the string is "1" or "[Tt]rue" +// Returns boolean false if the string is "0" or "[Ff]alse" +func (b *UnmarshallableBool) UnmarshalText(data []byte) error { + s := strings.ToLower(string(data)) + switch s { + case "1", "true": + *b = true + case "0", "false": + *b = false + default: + return fmt.Errorf("Boolean unmarshal error: invalid input %s", s) + } + return nil +} + +// UnmarshallableString typedef for builtin string +type UnmarshallableString string + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +// Returns the string +func (s *UnmarshallableString) UnmarshalText(data []byte) error { + *s = UnmarshallableString(data) + return nil +} + +// CommonArgs contains the IgnoreUnknown argument +// and must be embedded by all Arg structs +type CommonArgs struct { + IgnoreUnknown UnmarshallableBool `json:"ignoreunknown,omitempty"` +} + +// GetKeyField is a helper function to receive Values +// Values that represent a pointer to a struct +func GetKeyField(keyString string, v reflect.Value) reflect.Value { + return v.Elem().FieldByName(keyString) +} + +// LoadArgs parses args from a string in the form "K=V;K2=V2;..." +func LoadArgs(args string, container interface{}) error { + if args == "" { + return nil + } + + containerValue := reflect.ValueOf(container) + + pairs := strings.Split(args, ";") + unknownArgs := []string{} + for _, pair := range pairs { + kv := strings.Split(pair, "=") + if len(kv) != 2 { + return fmt.Errorf("ARGS: invalid pair %q", pair) + } + keyString := kv[0] + valueString := kv[1] + keyField := GetKeyField(keyString, containerValue) + if !keyField.IsValid() { + unknownArgs = append(unknownArgs, pair) + continue + } + + u := keyField.Addr().Interface().(encoding.TextUnmarshaler) + err := u.UnmarshalText([]byte(valueString)) + if err != nil { + return fmt.Errorf("ARGS: error parsing value of pair %q: %v)", pair, err) + } + } + + isIgnoreUnknown := GetKeyField("IgnoreUnknown", containerValue).Bool() + if len(unknownArgs) > 0 && !isIgnoreUnknown { + return fmt.Errorf("ARGS: unknown args %q", unknownArgs) + } + return nil +} diff --git a/pkg/types/args_test.go b/pkg/types/args_test.go new file mode 100644 index 00000000..3a53d9a4 --- /dev/null +++ b/pkg/types/args_test.go @@ -0,0 +1,121 @@ +// 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 types_test + +import ( + "reflect" + + . "github.com/containernetworking/cni/pkg/types" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +var _ = Describe("UnmarshallableBool UnmarshalText", func() { + DescribeTable("string to bool detection should succeed in all cases", + func(inputs []string, expected bool) { + for _, s := range inputs { + var ub UnmarshallableBool + err := ub.UnmarshalText([]byte(s)) + Expect(err).ToNot(HaveOccurred()) + Expect(ub).To(Equal(UnmarshallableBool(expected))) + } + }, + Entry("parse to true", []string{"True", "true", "1"}, true), + Entry("parse to false", []string{"False", "false", "0"}, false), + ) + + Context("When passed an invalid value", func() { + It("should result in an error", func() { + var ub UnmarshallableBool + err := ub.UnmarshalText([]byte("invalid")) + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("UnmarshallableString UnmarshalText", func() { + DescribeTable("string to string detection should succeed in all cases", + func(inputs []string, expected string) { + for _, s := range inputs { + var us UnmarshallableString + err := us.UnmarshalText([]byte(s)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(us)).To(Equal(expected)) + } + }, + Entry("parse empty string", []string{""}, ""), + Entry("parse non-empty string", []string{"notempty"}, "notempty"), + ) +}) + +var _ = Describe("GetKeyField", func() { + type testcontainer struct { + Valid string `json:"valid,omitempty"` + } + var ( + container = testcontainer{Valid: "valid"} + containerInterface = func(i interface{}) interface{} { return i }(&container) + containerValue = reflect.ValueOf(containerInterface) + ) + Context("When a valid field is provided", func() { + It("should return the correct field", func() { + field := GetKeyField("Valid", containerValue) + Expect(field.String()).To(Equal("valid")) + }) + }) +}) + +var _ = Describe("LoadArgs", func() { + Context("When no arguments are passed", func() { + It("LoadArgs should succeed", func() { + err := LoadArgs("", struct{}{}) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("When unknown arguments are passed and ignored", func() { + It("LoadArgs should succeed", func() { + ca := CommonArgs{} + err := LoadArgs("IgnoreUnknown=True;Unk=nown", &ca) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("When unknown arguments are passed and not ignored", func() { + It("LoadArgs should fail", func() { + ca := CommonArgs{} + err := LoadArgs("Unk=nown", &ca) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When unknown arguments are passed and explicitly not ignored", func() { + It("LoadArgs should fail", func() { + ca := CommonArgs{} + err := LoadArgs("IgnoreUnknown=0, Unk=nown", &ca) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When known arguments are passed", func() { + It("LoadArgs should succeed", func() { + ca := CommonArgs{} + err := LoadArgs("IgnoreUnknown=1", &ca) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/pkg/types/current/types.go b/pkg/types/current/types.go new file mode 100644 index 00000000..b5715fe6 --- /dev/null +++ b/pkg/types/current/types.go @@ -0,0 +1,296 @@ +// 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 current + +import ( + "encoding/json" + "fmt" + "net" + "os" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/020" +) + +const ImplementedSpecVersion string = "0.3.1" + +var SupportedVersions = []string{"0.3.0", ImplementedSpecVersion} + +func NewResult(data []byte) (types.Result, error) { + result := &Result{} + if err := json.Unmarshal(data, result); err != nil { + return nil, err + } + return result, nil +} + +func GetResult(r types.Result) (*Result, error) { + resultCurrent, err := r.GetAsVersion(ImplementedSpecVersion) + if err != nil { + return nil, err + } + result, ok := resultCurrent.(*Result) + if !ok { + return nil, fmt.Errorf("failed to convert result") + } + return result, nil +} + +var resultConverters = []struct { + versions []string + convert func(types.Result) (*Result, error) +}{ + {types020.SupportedVersions, convertFrom020}, + {SupportedVersions, convertFrom030}, +} + +func convertFrom020(result types.Result) (*Result, error) { + oldResult, err := types020.GetResult(result) + if err != nil { + return nil, err + } + + newResult := &Result{ + CNIVersion: ImplementedSpecVersion, + DNS: oldResult.DNS, + Routes: []*types.Route{}, + } + + if oldResult.IP4 != nil { + newResult.IPs = append(newResult.IPs, &IPConfig{ + Version: "4", + Interface: -1, + Address: oldResult.IP4.IP, + Gateway: oldResult.IP4.Gateway, + }) + for _, route := range oldResult.IP4.Routes { + gw := route.GW + if gw == nil { + gw = oldResult.IP4.Gateway + } + newResult.Routes = append(newResult.Routes, &types.Route{ + Dst: route.Dst, + GW: gw, + }) + } + } + + if oldResult.IP6 != nil { + newResult.IPs = append(newResult.IPs, &IPConfig{ + Version: "6", + Interface: -1, + Address: oldResult.IP6.IP, + Gateway: oldResult.IP6.Gateway, + }) + for _, route := range oldResult.IP6.Routes { + gw := route.GW + if gw == nil { + gw = oldResult.IP6.Gateway + } + newResult.Routes = append(newResult.Routes, &types.Route{ + Dst: route.Dst, + GW: gw, + }) + } + } + + if len(newResult.IPs) == 0 { + return nil, fmt.Errorf("cannot convert: no valid IP addresses") + } + + return newResult, nil +} + +func convertFrom030(result types.Result) (*Result, error) { + newResult, ok := result.(*Result) + if !ok { + return nil, fmt.Errorf("failed to convert result") + } + newResult.CNIVersion = ImplementedSpecVersion + return newResult, nil +} + +func NewResultFromResult(result types.Result) (*Result, error) { + version := result.Version() + for _, converter := range resultConverters { + for _, supportedVersion := range converter.versions { + if version == supportedVersion { + return converter.convert(result) + } + } + } + return nil, fmt.Errorf("unsupported CNI result22 version %q", version) +} + +// Result is what gets returned from the plugin (via stdout) to the caller +type Result struct { + CNIVersion string `json:"cniVersion,omitempty"` + Interfaces []*Interface `json:"interfaces,omitempty"` + IPs []*IPConfig `json:"ips,omitempty"` + Routes []*types.Route `json:"routes,omitempty"` + DNS types.DNS `json:"dns,omitempty"` +} + +// Convert to the older 0.2.0 CNI spec Result type +func (r *Result) convertTo020() (*types020.Result, error) { + oldResult := &types020.Result{ + CNIVersion: types020.ImplementedSpecVersion, + DNS: r.DNS, + } + + for _, ip := range r.IPs { + // Only convert the first IP address of each version as 0.2.0 + // and earlier cannot handle multiple IP addresses + if ip.Version == "4" && oldResult.IP4 == nil { + oldResult.IP4 = &types020.IPConfig{ + IP: ip.Address, + Gateway: ip.Gateway, + } + } else if ip.Version == "6" && oldResult.IP6 == nil { + oldResult.IP6 = &types020.IPConfig{ + IP: ip.Address, + Gateway: ip.Gateway, + } + } + + if oldResult.IP4 != nil && oldResult.IP6 != nil { + break + } + } + + for _, route := range r.Routes { + is4 := route.Dst.IP.To4() != nil + if is4 && oldResult.IP4 != nil { + oldResult.IP4.Routes = append(oldResult.IP4.Routes, types.Route{ + Dst: route.Dst, + GW: route.GW, + }) + } else if !is4 && oldResult.IP6 != nil { + oldResult.IP6.Routes = append(oldResult.IP6.Routes, types.Route{ + Dst: route.Dst, + GW: route.GW, + }) + } + } + + if oldResult.IP4 == nil && oldResult.IP6 == nil { + return nil, fmt.Errorf("cannot convert: no valid IP addresses") + } + + return oldResult, nil +} + +func (r *Result) Version() string { + return ImplementedSpecVersion +} + +func (r *Result) GetAsVersion(version string) (types.Result, error) { + switch version { + case "0.3.0", ImplementedSpecVersion: + r.CNIVersion = version + return r, nil + case types020.SupportedVersions[0], types020.SupportedVersions[1], types020.SupportedVersions[2]: + return r.convertTo020() + } + return nil, fmt.Errorf("cannot convert version 0.3.x to %q", version) +} + +func (r *Result) Print() error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + _, err = os.Stdout.Write(data) + return err +} + +// String returns a formatted string in the form of "[Interfaces: $1,][ IP: $2,] DNS: $3" where +// $1 represents the receiver's Interfaces, $2 represents the receiver's IP addresses and $3 the +// receiver's DNS. If $1 or $2 are nil, they won't be present in the returned string. +func (r *Result) String() string { + var str string + if len(r.Interfaces) > 0 { + str += fmt.Sprintf("Interfaces:%+v, ", r.Interfaces) + } + if len(r.IPs) > 0 { + str += fmt.Sprintf("IP:%+v, ", r.IPs) + } + if len(r.Routes) > 0 { + str += fmt.Sprintf("Routes:%+v, ", r.Routes) + } + return fmt.Sprintf("%sDNS:%+v", str, r.DNS) +} + +// Convert this old version result to the current CNI version result +func (r *Result) Convert() (*Result, error) { + return r, nil +} + +// Interface contains values about the created interfaces +type Interface struct { + Name string `json:"name"` + Mac string `json:"mac,omitempty"` + Sandbox string `json:"sandbox,omitempty"` +} + +func (i *Interface) String() string { + return fmt.Sprintf("%+v", *i) +} + +// IPConfig contains values necessary to configure an IP address on an interface +type IPConfig struct { + // IP version, either "4" or "6" + Version string + // Index into Result structs Interfaces list + Interface int + Address net.IPNet + Gateway net.IP +} + +func (i *IPConfig) String() string { + return fmt.Sprintf("%+v", *i) +} + +// JSON (un)marshallable types +type ipConfig struct { + Version string `json:"version"` + Interface int `json:"interface,omitempty"` + Address types.IPNet `json:"address"` + Gateway net.IP `json:"gateway,omitempty"` +} + +func (c *IPConfig) MarshalJSON() ([]byte, error) { + ipc := ipConfig{ + Version: c.Version, + Interface: c.Interface, + Address: types.IPNet(c.Address), + Gateway: c.Gateway, + } + + return json.Marshal(ipc) +} + +func (c *IPConfig) UnmarshalJSON(data []byte) error { + ipc := ipConfig{} + if err := json.Unmarshal(data, &ipc); err != nil { + return err + } + + c.Version = ipc.Version + c.Interface = ipc.Interface + c.Address = net.IPNet(ipc.Address) + c.Gateway = ipc.Gateway + return nil +} diff --git a/pkg/types/current/types_suite_test.go b/pkg/types/current/types_suite_test.go new file mode 100644 index 00000000..e05c1ff1 --- /dev/null +++ b/pkg/types/current/types_suite_test.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 current_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestTypesCurrent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Current Types Suite") +} diff --git a/pkg/types/current/types_test.go b/pkg/types/current/types_test.go new file mode 100644 index 00000000..afc68670 --- /dev/null +++ b/pkg/types/current/types_test.go @@ -0,0 +1,215 @@ +// 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 current_test + +import ( + "io/ioutil" + "net" + "os" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/current" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func testResult() *current.Result { + ipv4, err := types.ParseCIDR("1.2.3.30/24") + Expect(err).NotTo(HaveOccurred()) + Expect(ipv4).NotTo(BeNil()) + + routegwv4, routev4, err := net.ParseCIDR("15.5.6.8/24") + Expect(err).NotTo(HaveOccurred()) + Expect(routev4).NotTo(BeNil()) + Expect(routegwv4).NotTo(BeNil()) + + ipv6, err := types.ParseCIDR("abcd:1234:ffff::cdde/64") + Expect(err).NotTo(HaveOccurred()) + Expect(ipv6).NotTo(BeNil()) + + routegwv6, routev6, err := net.ParseCIDR("1111:dddd::aaaa/80") + Expect(err).NotTo(HaveOccurred()) + Expect(routev6).NotTo(BeNil()) + Expect(routegwv6).NotTo(BeNil()) + + // Set every field of the struct to ensure source compatibility + return ¤t.Result{ + CNIVersion: "0.3.1", + Interfaces: []*current.Interface{ + { + Name: "eth0", + Mac: "00:11:22:33:44:55", + Sandbox: "/proc/3553/ns/net", + }, + }, + IPs: []*current.IPConfig{ + { + Version: "4", + Interface: 0, + Address: *ipv4, + Gateway: net.ParseIP("1.2.3.1"), + }, + { + Version: "6", + Interface: 0, + Address: *ipv6, + Gateway: net.ParseIP("abcd:1234:ffff::1"), + }, + }, + Routes: []*types.Route{ + {Dst: *routev4, GW: routegwv4}, + {Dst: *routev6, GW: routegwv6}, + }, + DNS: types.DNS{ + Nameservers: []string{"1.2.3.4", "1::cafe"}, + Domain: "acompany.com", + Search: []string{"somedomain.com", "otherdomain.net"}, + Options: []string{"foo", "bar"}, + }, + } +} + +var _ = Describe("Current types operations", func() { + It("correctly encodes a 0.3.x Result", func() { + res := testResult() + + Expect(res.String()).To(Equal("Interfaces:[{Name:eth0 Mac:00:11:22:33:44:55 Sandbox:/proc/3553/ns/net}], IP:[{Version:4 Interface:0 Address:{IP:1.2.3.30 Mask:ffffff00} Gateway:1.2.3.1} {Version:6 Interface:0 Address:{IP:abcd:1234:ffff::cdde Mask:ffffffffffffffff0000000000000000} Gateway:abcd:1234:ffff::1}], Routes:[{Dst:{IP:15.5.6.0 Mask:ffffff00} GW:15.5.6.8} {Dst:{IP:1111:dddd:: Mask:ffffffffffffffffffff000000000000} GW:1111:dddd::aaaa}], DNS:{Nameservers:[1.2.3.4 1::cafe] Domain:acompany.com Search:[somedomain.com otherdomain.net] Options:[foo bar]}")) + + // Redirect stdout to capture JSON result + oldStdout := os.Stdout + r, w, err := os.Pipe() + Expect(err).NotTo(HaveOccurred()) + + os.Stdout = w + err = res.Print() + w.Close() + Expect(err).NotTo(HaveOccurred()) + + // parse the result + out, err := ioutil.ReadAll(r) + os.Stdout = oldStdout + Expect(err).NotTo(HaveOccurred()) + + Expect(string(out)).To(Equal(`{ + "cniVersion": "0.3.1", + "interfaces": [ + { + "name": "eth0", + "mac": "00:11:22:33:44:55", + "sandbox": "/proc/3553/ns/net" + } + ], + "ips": [ + { + "version": "4", + "address": "1.2.3.30/24", + "gateway": "1.2.3.1" + }, + { + "version": "6", + "address": "abcd:1234:ffff::cdde/64", + "gateway": "abcd:1234:ffff::1" + } + ], + "routes": [ + { + "dst": "15.5.6.0/24", + "gw": "15.5.6.8" + }, + { + "dst": "1111:dddd::/80", + "gw": "1111:dddd::aaaa" + } + ], + "dns": { + "nameservers": [ + "1.2.3.4", + "1::cafe" + ], + "domain": "acompany.com", + "search": [ + "somedomain.com", + "otherdomain.net" + ], + "options": [ + "foo", + "bar" + ] + } +}`)) + }) + + It("correctly encodes a 0.1.0 Result", func() { + res, err := testResult().GetAsVersion("0.1.0") + Expect(err).NotTo(HaveOccurred()) + + Expect(res.String()).To(Equal("IP4:{IP:{IP:1.2.3.30 Mask:ffffff00} Gateway:1.2.3.1 Routes:[{Dst:{IP:15.5.6.0 Mask:ffffff00} GW:15.5.6.8}]}, IP6:{IP:{IP:abcd:1234:ffff::cdde Mask:ffffffffffffffff0000000000000000} Gateway:abcd:1234:ffff::1 Routes:[{Dst:{IP:1111:dddd:: Mask:ffffffffffffffffffff000000000000} GW:1111:dddd::aaaa}]}, DNS:{Nameservers:[1.2.3.4 1::cafe] Domain:acompany.com Search:[somedomain.com otherdomain.net] Options:[foo bar]}")) + + // Redirect stdout to capture JSON result + oldStdout := os.Stdout + r, w, err := os.Pipe() + Expect(err).NotTo(HaveOccurred()) + + os.Stdout = w + err = res.Print() + w.Close() + Expect(err).NotTo(HaveOccurred()) + + // parse the result + out, err := ioutil.ReadAll(r) + os.Stdout = oldStdout + Expect(err).NotTo(HaveOccurred()) + + Expect(string(out)).To(Equal(`{ + "cniVersion": "0.2.0", + "ip4": { + "ip": "1.2.3.30/24", + "gateway": "1.2.3.1", + "routes": [ + { + "dst": "15.5.6.0/24", + "gw": "15.5.6.8" + } + ] + }, + "ip6": { + "ip": "abcd:1234:ffff::cdde/64", + "gateway": "abcd:1234:ffff::1", + "routes": [ + { + "dst": "1111:dddd::/80", + "gw": "1111:dddd::aaaa" + } + ] + }, + "dns": { + "nameservers": [ + "1.2.3.4", + "1::cafe" + ], + "domain": "acompany.com", + "search": [ + "somedomain.com", + "otherdomain.net" + ], + "options": [ + "foo", + "bar" + ] + } +}`)) + }) +}) diff --git a/pkg/types/types.go b/pkg/types/types.go new file mode 100644 index 00000000..3263015a --- /dev/null +++ b/pkg/types/types.go @@ -0,0 +1,185 @@ +// Copyright 2015 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 types + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "os" +) + +// like net.IPNet but adds JSON marshalling and unmarshalling +type IPNet net.IPNet + +// ParseCIDR takes a string like "10.2.3.1/24" and +// return IPNet with "10.2.3.1" and /24 mask +func ParseCIDR(s string) (*net.IPNet, error) { + ip, ipn, err := net.ParseCIDR(s) + if err != nil { + return nil, err + } + + ipn.IP = ip + return ipn, nil +} + +func (n IPNet) MarshalJSON() ([]byte, error) { + return json.Marshal((*net.IPNet)(&n).String()) +} + +func (n *IPNet) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + tmp, err := ParseCIDR(s) + if err != nil { + return err + } + + *n = IPNet(*tmp) + return nil +} + +// NetConf describes a network. +type NetConf struct { + CNIVersion string `json:"cniVersion,omitempty"` + + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` + IPAM struct { + Type string `json:"type,omitempty"` + } `json:"ipam,omitempty"` + DNS DNS `json:"dns"` +} + +// NetConfList describes an ordered list of networks. +type NetConfList struct { + CNIVersion string `json:"cniVersion,omitempty"` + + Name string `json:"name,omitempty"` + Plugins []*NetConf `json:"plugins,omitempty"` +} + +type ResultFactoryFunc func([]byte) (Result, error) + +// Result is an interface that provides the result of plugin execution +type Result interface { + // The highest CNI specification result verison the result supports + // without having to convert + Version() string + + // Returns the result converted into the requested CNI specification + // result version, or an error if conversion failed + GetAsVersion(version string) (Result, error) + + // Prints the result in JSON format to stdout + Print() error + + // Returns a JSON string representation of the result + String() string +} + +func PrintResult(result Result, version string) error { + newResult, err := result.GetAsVersion(version) + if err != nil { + return err + } + return newResult.Print() +} + +// DNS contains values interesting for DNS resolvers +type DNS struct { + Nameservers []string `json:"nameservers,omitempty"` + Domain string `json:"domain,omitempty"` + Search []string `json:"search,omitempty"` + Options []string `json:"options,omitempty"` +} + +type Route struct { + Dst net.IPNet + GW net.IP +} + +func (r *Route) String() string { + return fmt.Sprintf("%+v", *r) +} + +// Well known error codes +// see https://github.com/containernetworking/cni/blob/master/SPEC.md#well-known-error-codes +const ( + ErrUnknown uint = iota // 0 + ErrIncompatibleCNIVersion // 1 + ErrUnsupportedField // 2 +) + +type Error struct { + Code uint `json:"code"` + Msg string `json:"msg"` + Details string `json:"details,omitempty"` +} + +func (e *Error) Error() string { + return e.Msg +} + +func (e *Error) Print() error { + return prettyPrint(e) +} + +// net.IPNet is not JSON (un)marshallable so this duality is needed +// for our custom IPNet type + +// JSON (un)marshallable types +type route struct { + Dst IPNet `json:"dst"` + GW net.IP `json:"gw,omitempty"` +} + +func (r *Route) UnmarshalJSON(data []byte) error { + rt := route{} + if err := json.Unmarshal(data, &rt); err != nil { + return err + } + + r.Dst = net.IPNet(rt.Dst) + r.GW = rt.GW + return nil +} + +func (r *Route) MarshalJSON() ([]byte, error) { + rt := route{ + Dst: IPNet(r.Dst), + GW: r.GW, + } + + return json.Marshal(rt) +} + +func prettyPrint(obj interface{}) error { + data, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return err + } + _, err = os.Stdout.Write(data) + return err +} + +// NotImplementedError is used to indicate that a method is not implemented for the given platform +var NotImplementedError = errors.New("Not Implemented") diff --git a/pkg/types/types_suite_test.go b/pkg/types/types_suite_test.go new file mode 100644 index 00000000..2b178cee --- /dev/null +++ b/pkg/types/types_suite_test.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 types_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestTypes(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Types Suite") +} diff --git a/pkg/utils/hwaddr/hwaddr.go b/pkg/utils/hwaddr/hwaddr.go new file mode 100644 index 00000000..aaf3b8a0 --- /dev/null +++ b/pkg/utils/hwaddr/hwaddr.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 hwaddr + +import ( + "fmt" + "net" +) + +const ( + ipRelevantByteLen = 4 + PrivateMACPrefixString = "0a:58" +) + +var ( + // private mac prefix safe to use + PrivateMACPrefix = []byte{0x0a, 0x58} +) + +type SupportIp4OnlyErr struct{ msg string } + +func (e SupportIp4OnlyErr) Error() string { return e.msg } + +type MacParseErr struct{ msg string } + +func (e MacParseErr) Error() string { return e.msg } + +type InvalidPrefixLengthErr struct{ msg string } + +func (e InvalidPrefixLengthErr) Error() string { return e.msg } + +// GenerateHardwareAddr4 generates 48 bit virtual mac addresses based on the IP4 input. +func GenerateHardwareAddr4(ip net.IP, prefix []byte) (net.HardwareAddr, error) { + switch { + + case ip.To4() == nil: + return nil, SupportIp4OnlyErr{msg: "GenerateHardwareAddr4 only supports valid IPv4 address as input"} + + case len(prefix) != len(PrivateMACPrefix): + return nil, InvalidPrefixLengthErr{msg: fmt.Sprintf( + "Prefix has length %d instead of %d", len(prefix), len(PrivateMACPrefix)), + } + } + + ipByteLen := len(ip) + return (net.HardwareAddr)( + append( + prefix, + ip[ipByteLen-ipRelevantByteLen:ipByteLen]...), + ), nil +} diff --git a/pkg/utils/hwaddr/hwaddr_suite_test.go b/pkg/utils/hwaddr/hwaddr_suite_test.go new file mode 100644 index 00000000..e3bbfe97 --- /dev/null +++ b/pkg/utils/hwaddr/hwaddr_suite_test.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 hwaddr_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestHwaddr(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Hwaddr Suite") +} diff --git a/pkg/utils/hwaddr/hwaddr_test.go b/pkg/utils/hwaddr/hwaddr_test.go new file mode 100644 index 00000000..b77ccd89 --- /dev/null +++ b/pkg/utils/hwaddr/hwaddr_test.go @@ -0,0 +1,74 @@ +// 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 hwaddr_test + +import ( + "net" + + "github.com/containernetworking/cni/pkg/utils/hwaddr" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Hwaddr", func() { + Context("Generate Hardware Address", func() { + It("generate hardware address based on ipv4 address", func() { + testCases := []struct { + ip net.IP + expectedMAC net.HardwareAddr + }{ + { + ip: net.ParseIP("10.0.0.2"), + expectedMAC: (net.HardwareAddr)(append(hwaddr.PrivateMACPrefix, 0x0a, 0x00, 0x00, 0x02)), + }, + { + ip: net.ParseIP("10.250.0.244"), + expectedMAC: (net.HardwareAddr)(append(hwaddr.PrivateMACPrefix, 0x0a, 0xfa, 0x00, 0xf4)), + }, + { + ip: net.ParseIP("172.17.0.2"), + expectedMAC: (net.HardwareAddr)(append(hwaddr.PrivateMACPrefix, 0xac, 0x11, 0x00, 0x02)), + }, + { + ip: net.IPv4(byte(172), byte(17), byte(0), byte(2)), + expectedMAC: (net.HardwareAddr)(append(hwaddr.PrivateMACPrefix, 0xac, 0x11, 0x00, 0x02)), + }, + } + + for _, tc := range testCases { + mac, err := hwaddr.GenerateHardwareAddr4(tc.ip, hwaddr.PrivateMACPrefix) + Expect(err).NotTo(HaveOccurred()) + Expect(mac).To(Equal(tc.expectedMAC)) + } + }) + + It("return error if input is not ipv4 address", func() { + testCases := []net.IP{ + net.ParseIP(""), + net.ParseIP("2001:db8:0:1:1:1:1:1"), + } + for _, tc := range testCases { + _, err := hwaddr.GenerateHardwareAddr4(tc, hwaddr.PrivateMACPrefix) + Expect(err).To(BeAssignableToTypeOf(hwaddr.SupportIp4OnlyErr{})) + } + }) + + It("return error if prefix is invalid", func() { + _, err := hwaddr.GenerateHardwareAddr4(net.ParseIP("10.0.0.2"), []byte{0x58}) + Expect(err).To(BeAssignableToTypeOf(hwaddr.InvalidPrefixLengthErr{})) + }) + }) +}) diff --git a/pkg/utils/sysctl/sysctl_linux.go b/pkg/utils/sysctl/sysctl_linux.go new file mode 100644 index 00000000..fe06d2d9 --- /dev/null +++ b/pkg/utils/sysctl/sysctl_linux.go @@ -0,0 +1,56 @@ +// 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 sysctl + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "strings" +) + +// Sysctl provides a method to set/get values from /proc/sys - in linux systems +// new interface to set/get values of variables formerly handled by sysctl syscall +// If optional `params` have only one string value - this function will +// set this value into corresponding sysctl variable +func Sysctl(name string, params ...string) (string, error) { + if len(params) > 1 { + return "", fmt.Errorf("unexcepted additional parameters") + } else if len(params) == 1 { + return setSysctl(name, params[0]) + } + return getSysctl(name) +} + +func getSysctl(name string) (string, error) { + fullName := filepath.Join("/proc/sys", strings.Replace(name, ".", "/", -1)) + fullName = filepath.Clean(fullName) + data, err := ioutil.ReadFile(fullName) + if err != nil { + return "", err + } + + return string(data[:len(data)-1]), nil +} + +func setSysctl(name, value string) (string, error) { + fullName := filepath.Join("/proc/sys", strings.Replace(name, ".", "/", -1)) + fullName = filepath.Clean(fullName) + if err := ioutil.WriteFile(fullName, []byte(value), 0644); err != nil { + return "", err + } + + return getSysctl(name) +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 00000000..33a2aa79 --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,41 @@ +// 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 utils + +import ( + "crypto/sha512" + "fmt" +) + +const ( + maxChainLength = 28 + chainPrefix = "CNI-" + prefixLength = len(chainPrefix) +) + +// Generates a chain name to be used with iptables. +// Ensures that the generated chain name is exactly +// maxChainLength chars in length +func FormatChainName(name string, id string) string { + chainBytes := sha512.Sum512([]byte(name + id)) + chain := fmt.Sprintf("%s%x", chainPrefix, chainBytes) + return chain[:maxChainLength] +} + +// FormatComment returns a comment used for easier +// rule identification within iptables. +func FormatComment(name string, id string) string { + return fmt.Sprintf("name: %q id: %q", name, id) +} diff --git a/pkg/utils/utils_suite_test.go b/pkg/utils/utils_suite_test.go new file mode 100644 index 00000000..ee614a70 --- /dev/null +++ b/pkg/utils/utils_suite_test.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 utils_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 00000000..d703de42 --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,51 @@ +// 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 utils + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Utils", func() { + It("must format a short name", func() { + chain := FormatChainName("test", "1234") + Expect(len(chain)).To(Equal(maxChainLength)) + Expect(chain).To(Equal("CNI-2bbe0c48b91a7d1b8a6753a8")) + }) + + It("must truncate a long name", func() { + chain := FormatChainName("testalongnamethatdoesnotmakesense", "1234") + Expect(len(chain)).To(Equal(maxChainLength)) + Expect(chain).To(Equal("CNI-374f33fe84ab0ed84dcdebe3")) + }) + + It("must be predictable", func() { + chain1 := FormatChainName("testalongnamethatdoesnotmakesense", "1234") + chain2 := FormatChainName("testalongnamethatdoesnotmakesense", "1234") + Expect(len(chain1)).To(Equal(maxChainLength)) + Expect(len(chain2)).To(Equal(maxChainLength)) + Expect(chain1).To(Equal(chain2)) + }) + + It("must change when a character changes", func() { + chain1 := FormatChainName("testalongnamethatdoesnotmakesense", "1234") + chain2 := FormatChainName("testalongnamethatdoesnotmakesense", "1235") + Expect(len(chain1)).To(Equal(maxChainLength)) + Expect(len(chain2)).To(Equal(maxChainLength)) + Expect(chain1).To(Equal("CNI-374f33fe84ab0ed84dcdebe3")) + Expect(chain1).NotTo(Equal(chain2)) + }) +}) diff --git a/pkg/version/conf.go b/pkg/version/conf.go new file mode 100644 index 00000000..3cca58bb --- /dev/null +++ b/pkg/version/conf.go @@ -0,0 +1,37 @@ +// 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 version + +import ( + "encoding/json" + "fmt" +) + +// ConfigDecoder can decode the CNI version available in network config data +type ConfigDecoder struct{} + +func (*ConfigDecoder) Decode(jsonBytes []byte) (string, error) { + var conf struct { + CNIVersion string `json:"cniVersion"` + } + err := json.Unmarshal(jsonBytes, &conf) + if err != nil { + return "", fmt.Errorf("decoding version from network config: %s", err) + } + if conf.CNIVersion == "" { + return "0.1.0", nil + } + return conf.CNIVersion, nil +} diff --git a/pkg/version/conf_test.go b/pkg/version/conf_test.go new file mode 100644 index 00000000..881c57ad --- /dev/null +++ b/pkg/version/conf_test.go @@ -0,0 +1,69 @@ +// 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 version_test + +import ( + "github.com/containernetworking/cni/pkg/version" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Decoding the version of network config", func() { + var ( + decoder *version.ConfigDecoder + configBytes []byte + ) + + BeforeEach(func() { + decoder = &version.ConfigDecoder{} + configBytes = []byte(`{ "cniVersion": "4.3.2" }`) + }) + + Context("when the version is explict", func() { + It("returns the version", func() { + version, err := decoder.Decode(configBytes) + Expect(err).NotTo(HaveOccurred()) + + Expect(version).To(Equal("4.3.2")) + }) + }) + + Context("when the version is not present in the config", func() { + BeforeEach(func() { + configBytes = []byte(`{ "not-a-version-field": "foo" }`) + }) + + It("assumes the config is version 0.1.0", func() { + version, err := decoder.Decode(configBytes) + Expect(err).NotTo(HaveOccurred()) + + Expect(version).To(Equal("0.1.0")) + }) + }) + + Context("when the config data is malformed", func() { + BeforeEach(func() { + configBytes = []byte(`{{{`) + }) + + It("returns a useful error", func() { + _, err := decoder.Decode(configBytes) + Expect(err).To(MatchError(HavePrefix( + "decoding version from network config: invalid character", + ))) + }) + }) +}) diff --git a/pkg/version/legacy_examples/example_runtime.go b/pkg/version/legacy_examples/example_runtime.go new file mode 100644 index 00000000..a461981f --- /dev/null +++ b/pkg/version/legacy_examples/example_runtime.go @@ -0,0 +1,167 @@ +// 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 legacy_examples + +// An ExampleRuntime is a small program that uses libcni to invoke a network plugin. +// It should call ADD and DELETE, verifying all intermediate steps +// and data structures. +type ExampleRuntime struct { + Example + NetConfs []string // The network configuration names to pass +} + +// NetConfs are various versioned network configuration files. Examples should +// specify which version they expect +var NetConfs = map[string]string{ + "unversioned": `{ + "name": "default", + "type": "ptp", + "ipam": { + "type": "host-local", + "subnet": "10.1.2.0/24" + } +}`, + "0.1.0": `{ + "cniVersion": "0.1.0", + "name": "default", + "type": "ptp", + "ipam": { + "type": "host-local", + "subnet": "10.1.2.0/24" + } +}`, +} + +// V010_Runtime creates a simple ptp network configuration, then +// executes libcni against the currently-built plugins. +var V010_Runtime = ExampleRuntime{ + NetConfs: []string{"unversioned", "0.1.0"}, + Example: Example{ + Name: "example_invoker_v010", + CNIRepoGitRef: "c0d34c69", //version with ns.Do + PluginSource: `package main + +import ( + "fmt" + "io/ioutil" + "net" + "os" + + "github.com/containernetworking/cni/pkg/ns" + "github.com/containernetworking/cni/libcni" +) + +func main(){ + code := exec() + os.Exit(code) +} + +func exec() int { + confBytes, err := ioutil.ReadAll(os.Stdin) + if err != nil { + fmt.Printf("could not read netconfig from stdin: %+v", err) + return 1 + } + + netConf, err := libcni.ConfFromBytes(confBytes) + if err != nil { + fmt.Printf("could not parse netconfig: %+v", err) + return 1 + } + fmt.Printf("Parsed network configuration: %+v\n", netConf.Network) + + if len(os.Args) == 1 { + fmt.Printf("Expect CNI plugin paths in argv") + return 1 + } + + targetNs, err := ns.NewNS() + if err != nil { + fmt.Printf("Could not create ns: %+v", err) + return 1 + } + defer targetNs.Close() + + ifName := "eth0" + + runtimeConf := &libcni.RuntimeConf{ + ContainerID: "some-container-id", + NetNS: targetNs.Path(), + IfName: ifName, + } + + cniConfig := &libcni.CNIConfig{Path: os.Args[1:]} + + result, err := cniConfig.AddNetwork(netConf, runtimeConf) + if err != nil { + fmt.Printf("AddNetwork failed: %+v", err) + return 2 + } + fmt.Printf("AddNetwork result: %+v", result) + + expectedIP := result.IP4.IP + + err = targetNs.Do(func(ns.NetNS) error { + netif, err := net.InterfaceByName(ifName) + if err != nil { + return fmt.Errorf("could not retrieve interface: %v", err) + } + + addrs, err := netif.Addrs() + if err != nil { + return fmt.Errorf("could not retrieve addresses, %+v", err) + } + + found := false + for _, addr := range addrs { + if addr.String() == expectedIP.String() { + found = true + break + } + } + + if !found { + return fmt.Errorf("Far-side link did not have expected address %s", expectedIP) + } + return nil + }) + if err != nil { + fmt.Println(err) + return 4 + } + + err = cniConfig.DelNetwork(netConf, runtimeConf) + if err != nil { + fmt.Printf("DelNetwork failed: %v", err) + return 5 + } + + err = targetNs.Do(func(ns.NetNS) error { + _, err := net.InterfaceByName(ifName) + if err == nil { + return fmt.Errorf("interface was not deleted") + } + return nil + }) + if err != nil { + fmt.Println(err) + return 6 + } + + return 0 +} +`, + }, +} diff --git a/pkg/version/legacy_examples/examples.go b/pkg/version/legacy_examples/examples.go new file mode 100644 index 00000000..1bf406b3 --- /dev/null +++ b/pkg/version/legacy_examples/examples.go @@ -0,0 +1,139 @@ +// 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 legacy_examples contains sample code from prior versions of +// the CNI library, for use in verifying backwards compatibility. +package legacy_examples + +import ( + "io/ioutil" + "net" + "path/filepath" + "sync" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/020" + "github.com/containernetworking/cni/pkg/version/testhelpers" +) + +// An Example is a Git reference to the CNI repo and a Golang CNI plugin that +// builds against that version of the repo. +// +// By convention, every Example plugin returns an ADD result that is +// semantically equivalent to the ExpectedResult. +type Example struct { + Name string + CNIRepoGitRef string + PluginSource string +} + +var buildDir = "" +var buildDirLock sync.Mutex + +func ensureBuildDirExists() error { + buildDirLock.Lock() + defer buildDirLock.Unlock() + + if buildDir != "" { + return nil + } + + var err error + buildDir, err = ioutil.TempDir("", "cni-example-plugins") + return err +} + +// Build builds the example, returning the path to the binary +func (e Example) Build() (string, error) { + if err := ensureBuildDirExists(); err != nil { + return "", err + } + + outBinPath := filepath.Join(buildDir, e.Name) + + if err := testhelpers.BuildAt([]byte(e.PluginSource), e.CNIRepoGitRef, outBinPath); err != nil { + return "", err + } + return outBinPath, nil +} + +// V010 acts like a CNI plugin from the v0.1.0 era +var V010 = Example{ + Name: "example_v010", + CNIRepoGitRef: "2c482f4", + PluginSource: `package main + +import ( + "net" + + "github.com/containernetworking/cni/pkg/skel" + "github.com/containernetworking/cni/pkg/types" +) + +var result = types.Result{ + IP4: &types.IPConfig{ + IP: net.IPNet{ + IP: net.ParseIP("10.1.2.3"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("10.1.2.1"), + Routes: []types.Route{ + types.Route{ + Dst: net.IPNet{ + IP: net.ParseIP("0.0.0.0"), + Mask: net.CIDRMask(0, 32), + }, + GW: net.ParseIP("10.1.0.1"), + }, + }, + }, + DNS: types.DNS{ + Nameservers: []string{"8.8.8.8"}, + Domain: "example.com", + }, +} + +func c(_ *skel.CmdArgs) error { result.Print(); return nil } + +func main() { skel.PluginMain(c, c) } +`, +} + +// ExpectedResult is the current representation of the plugin result +// that is expected from each of the examples. +// +// As we change the CNI spec, the Result type and this value may change. +// The text of the example plugins should not. +var ExpectedResult = &types020.Result{ + IP4: &types020.IPConfig{ + IP: net.IPNet{ + IP: net.ParseIP("10.1.2.3"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("10.1.2.1"), + Routes: []types.Route{ + types.Route{ + Dst: net.IPNet{ + IP: net.ParseIP("0.0.0.0"), + Mask: net.CIDRMask(0, 32), + }, + GW: net.ParseIP("10.1.0.1"), + }, + }, + }, + DNS: types.DNS{ + Nameservers: []string{"8.8.8.8"}, + Domain: "example.com", + }, +} diff --git a/pkg/version/legacy_examples/legacy_examples_suite_test.go b/pkg/version/legacy_examples/legacy_examples_suite_test.go new file mode 100644 index 00000000..a126531d --- /dev/null +++ b/pkg/version/legacy_examples/legacy_examples_suite_test.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 legacy_examples_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestLegacyExamples(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "LegacyExamples Suite") +} diff --git a/pkg/version/legacy_examples/legacy_examples_test.go b/pkg/version/legacy_examples/legacy_examples_test.go new file mode 100644 index 00000000..41151056 --- /dev/null +++ b/pkg/version/legacy_examples/legacy_examples_test.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 legacy_examples_test + +import ( + "os" + "path/filepath" + + "github.com/containernetworking/cni/pkg/version/legacy_examples" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("The v0.1.0 Example", func() { + It("builds ok", func() { + example := legacy_examples.V010 + pluginPath, err := example.Build() + Expect(err).NotTo(HaveOccurred()) + + Expect(filepath.Base(pluginPath)).To(Equal(example.Name)) + + Expect(os.RemoveAll(pluginPath)).To(Succeed()) + }) +}) diff --git a/pkg/version/plugin.go b/pkg/version/plugin.go new file mode 100644 index 00000000..8a467281 --- /dev/null +++ b/pkg/version/plugin.go @@ -0,0 +1,81 @@ +// 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 version + +import ( + "encoding/json" + "fmt" + "io" +) + +// PluginInfo reports information about CNI versioning +type PluginInfo interface { + // SupportedVersions returns one or more CNI spec versions that the plugin + // supports. If input is provided in one of these versions, then the plugin + // promises to use the same CNI version in its response + SupportedVersions() []string + + // Encode writes this CNI version information as JSON to the given Writer + Encode(io.Writer) error +} + +type pluginInfo struct { + CNIVersion_ string `json:"cniVersion"` + SupportedVersions_ []string `json:"supportedVersions,omitempty"` +} + +// pluginInfo implements the PluginInfo interface +var _ PluginInfo = &pluginInfo{} + +func (p *pluginInfo) Encode(w io.Writer) error { + return json.NewEncoder(w).Encode(p) +} + +func (p *pluginInfo) SupportedVersions() []string { + return p.SupportedVersions_ +} + +// PluginSupports returns a new PluginInfo that will report the given versions +// as supported +func PluginSupports(supportedVersions ...string) PluginInfo { + if len(supportedVersions) < 1 { + panic("programmer error: you must support at least one version") + } + return &pluginInfo{ + CNIVersion_: Current(), + SupportedVersions_: supportedVersions, + } +} + +// PluginDecoder can decode the response returned by a plugin's VERSION command +type PluginDecoder struct{} + +func (*PluginDecoder) Decode(jsonBytes []byte) (PluginInfo, error) { + var info pluginInfo + err := json.Unmarshal(jsonBytes, &info) + if err != nil { + return nil, fmt.Errorf("decoding version info: %s", err) + } + if info.CNIVersion_ == "" { + return nil, fmt.Errorf("decoding version info: missing field cniVersion") + } + if len(info.SupportedVersions_) == 0 { + if info.CNIVersion_ == "0.2.0" { + return PluginSupports("0.1.0", "0.2.0"), nil + } + return nil, fmt.Errorf("decoding version info: missing field supportedVersions") + } + return &info, nil +} diff --git a/pkg/version/plugin_test.go b/pkg/version/plugin_test.go new file mode 100644 index 00000000..124288fd --- /dev/null +++ b/pkg/version/plugin_test.go @@ -0,0 +1,85 @@ +// 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 version_test + +import ( + "github.com/containernetworking/cni/pkg/version" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Decoding versions reported by a plugin", func() { + var ( + decoder *version.PluginDecoder + versionStdout []byte + ) + + BeforeEach(func() { + decoder = &version.PluginDecoder{} + versionStdout = []byte(`{ + "cniVersion": "some-library-version", + "supportedVersions": [ "some-version", "some-other-version" ] + }`) + }) + + It("returns a PluginInfo that represents the given json bytes", func() { + pluginInfo, err := decoder.Decode(versionStdout) + Expect(err).NotTo(HaveOccurred()) + Expect(pluginInfo).NotTo(BeNil()) + Expect(pluginInfo.SupportedVersions()).To(Equal([]string{ + "some-version", + "some-other-version", + })) + }) + + Context("when the bytes cannot be decoded as json", func() { + BeforeEach(func() { + versionStdout = []byte(`{{{`) + }) + + It("returns a meaningful error", func() { + _, err := decoder.Decode(versionStdout) + 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() { + BeforeEach(func() { + versionStdout = []byte(`{ "supportedVersions": [ "foo" ] }`) + }) + + It("returns a meaningful error", func() { + _, err := decoder.Decode(versionStdout) + Expect(err).To(MatchError("decoding version info: missing field cniVersion")) + }) + }) + + Context("when there are no supported versions", func() { + BeforeEach(func() { + versionStdout = []byte(`{ "cniVersion": "0.2.0" }`) + }) + + It("assumes that the supported versions are 0.1.0 and 0.2.0", func() { + pluginInfo, err := decoder.Decode(versionStdout) + Expect(err).NotTo(HaveOccurred()) + Expect(pluginInfo).NotTo(BeNil()) + Expect(pluginInfo.SupportedVersions()).To(Equal([]string{ + "0.1.0", + "0.2.0", + })) + }) + }) + +}) diff --git a/pkg/version/reconcile.go b/pkg/version/reconcile.go new file mode 100644 index 00000000..25c3810b --- /dev/null +++ b/pkg/version/reconcile.go @@ -0,0 +1,49 @@ +// 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 version + +import "fmt" + +type ErrorIncompatible struct { + Config string + Supported []string +} + +func (e *ErrorIncompatible) Details() string { + return fmt.Sprintf("config is %q, plugin supports %q", e.Config, e.Supported) +} + +func (e *ErrorIncompatible) Error() string { + return fmt.Sprintf("incompatible CNI versions: %s", e.Details()) +} + +type Reconciler struct{} + +func (r *Reconciler) Check(configVersion string, pluginInfo PluginInfo) *ErrorIncompatible { + return r.CheckRaw(configVersion, pluginInfo.SupportedVersions()) +} + +func (*Reconciler) CheckRaw(configVersion string, supportedVersions []string) *ErrorIncompatible { + for _, supportedVersion := range supportedVersions { + if configVersion == supportedVersion { + return nil + } + } + + return &ErrorIncompatible{ + Config: configVersion, + Supported: supportedVersions, + } +} diff --git a/pkg/version/reconcile_test.go b/pkg/version/reconcile_test.go new file mode 100644 index 00000000..0c964cea --- /dev/null +++ b/pkg/version/reconcile_test.go @@ -0,0 +1,51 @@ +// 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 version_test + +import ( + "github.com/containernetworking/cni/pkg/version" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Reconcile versions of net config with versions supported by plugins", func() { + var ( + reconciler *version.Reconciler + pluginInfo version.PluginInfo + ) + + BeforeEach(func() { + reconciler = &version.Reconciler{} + pluginInfo = version.PluginSupports("1.2.3", "4.3.2") + }) + + It("succeeds if the config version is supported by the plugin", func() { + err := reconciler.Check("4.3.2", pluginInfo) + Expect(err).NotTo(HaveOccurred()) + }) + + Context("when the config version is not supported by the plugin", func() { + It("returns a helpful error", func() { + err := reconciler.Check("0.1.0", pluginInfo) + + Expect(err).To(Equal(&version.ErrorIncompatible{ + Config: "0.1.0", + Supported: []string{"1.2.3", "4.3.2"}, + })) + + Expect(err.Error()).To(Equal(`incompatible CNI versions: config is "0.1.0", plugin supports ["1.2.3" "4.3.2"]`)) + }) + }) +}) diff --git a/pkg/version/testhelpers/testhelpers.go b/pkg/version/testhelpers/testhelpers.go new file mode 100644 index 00000000..773d0120 --- /dev/null +++ b/pkg/version/testhelpers/testhelpers.go @@ -0,0 +1,156 @@ +// 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 testhelpers supports testing of CNI components of different versions +// +// For example, to build a plugin against an old version of the CNI library, +// we can pass the plugin's source and the old git commit reference to BuildAt. +// We could then test how the built binary responds when called by the latest +// version of this library. +package testhelpers + +import ( + "fmt" + "io/ioutil" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const packageBaseName = "github.com/containernetworking/cni" + +func run(cmd *exec.Cmd) error { + out, err := cmd.CombinedOutput() + if err != nil { + command := strings.Join(cmd.Args, " ") + return fmt.Errorf("running %q: %s", command, out) + } + return nil +} + +func goBuildEnviron(gopath string) []string { + environ := os.Environ() + for i, kvp := range environ { + if strings.HasPrefix(kvp, "GOPATH=") { + environ[i] = "GOPATH=" + gopath + return environ + } + } + environ = append(environ, "GOPATH="+gopath) + return environ +} + +func buildGoProgram(gopath, packageName, outputFilePath string) error { + cmd := exec.Command("go", "build", "-o", outputFilePath, packageName) + cmd.Env = goBuildEnviron(gopath) + return run(cmd) +} + +func createSingleFilePackage(gopath, packageName string, fileContents []byte) error { + dirName := filepath.Join(gopath, "src", packageName) + err := os.MkdirAll(dirName, 0700) + if err != nil { + return err + } + + return ioutil.WriteFile(filepath.Join(dirName, "main.go"), fileContents, 0600) +} + +func removePackage(gopath, packageName string) error { + dirName := filepath.Join(gopath, "src", packageName) + return os.RemoveAll(dirName) +} + +func isRepoRoot(path string) bool { + _, err := ioutil.ReadDir(filepath.Join(path, ".git")) + return (err == nil) && (filepath.Base(path) == "cni") +} + +func LocateCurrentGitRepo() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for i := 0; i < 5; i++ { + if isRepoRoot(dir) { + return dir, nil + } + + dir, err = filepath.Abs(filepath.Dir(dir)) + if err != nil { + return "", fmt.Errorf("abs(dir(%q)): %s", dir, err) + } + } + + return "", fmt.Errorf("unable to find cni repo root, landed at %q", dir) +} + +func gitCloneThisRepo(cloneDestination string) error { + err := os.MkdirAll(cloneDestination, 0700) + if err != nil { + return err + } + + currentGitRepo, err := LocateCurrentGitRepo() + if err != nil { + return err + } + + return run(exec.Command("git", "clone", currentGitRepo, cloneDestination)) +} + +func gitCheckout(localRepo string, gitRef string) error { + return run(exec.Command("git", "-C", localRepo, "checkout", gitRef)) +} + +// BuildAt builds the go programSource using the version of the CNI library +// at gitRef, and saves the resulting binary file at outputFilePath +func BuildAt(programSource []byte, gitRef string, outputFilePath string) error { + tempGoPath, err := ioutil.TempDir("", "cni-git-") + if err != nil { + return err + } + defer os.RemoveAll(tempGoPath) + + cloneDestination := filepath.Join(tempGoPath, "src", packageBaseName) + err = gitCloneThisRepo(cloneDestination) + if err != nil { + return err + } + + err = gitCheckout(cloneDestination, gitRef) + if err != nil { + return err + } + + rand.Seed(time.Now().UnixNano()) + testPackageName := fmt.Sprintf("test-package-%x", rand.Int31()) + + err = createSingleFilePackage(tempGoPath, testPackageName, programSource) + if err != nil { + return err + } + defer removePackage(tempGoPath, testPackageName) + + err = buildGoProgram(tempGoPath, testPackageName, outputFilePath) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/version/testhelpers/testhelpers_suite_test.go b/pkg/version/testhelpers/testhelpers_suite_test.go new file mode 100644 index 00000000..72f65f9c --- /dev/null +++ b/pkg/version/testhelpers/testhelpers_suite_test.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 testhelpers_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestTesthelpers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Testhelpers Suite") +} diff --git a/pkg/version/testhelpers/testhelpers_test.go b/pkg/version/testhelpers/testhelpers_test.go new file mode 100644 index 00000000..3473cd59 --- /dev/null +++ b/pkg/version/testhelpers/testhelpers_test.go @@ -0,0 +1,106 @@ +// 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 testhelpers_test + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + + "github.com/containernetworking/cni/pkg/version/testhelpers" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("BuildAt", func() { + var ( + gitRef string + outputFilePath string + outputDir string + programSource []byte + ) + BeforeEach(func() { + programSource = []byte(`package main + +import "github.com/containernetworking/cni/pkg/skel" + +func c(_ *skel.CmdArgs) error { return nil } + +func main() { skel.PluginMain(c, c) } +`) + gitRef = "f4364185253" + + var err error + outputDir, err = ioutil.TempDir("", "bin") + Expect(err).NotTo(HaveOccurred()) + outputFilePath = filepath.Join(outputDir, "some-binary") + }) + + AfterEach(func() { + Expect(os.RemoveAll(outputDir)).To(Succeed()) + }) + + It("builds the provided source code using the CNI library at the given git ref", func() { + Expect(outputFilePath).NotTo(BeAnExistingFile()) + + err := testhelpers.BuildAt(programSource, gitRef, outputFilePath) + Expect(err).NotTo(HaveOccurred()) + + Expect(outputFilePath).To(BeAnExistingFile()) + + cmd := exec.Command(outputFilePath) + cmd.Env = []string{"CNI_COMMAND=VERSION"} + output, err := cmd.CombinedOutput() + Expect(err).To(BeAssignableToTypeOf(&exec.ExitError{})) + Expect(output).To(ContainSubstring("unknown CNI_COMMAND: VERSION")) + }) +}) + +var _ = Describe("LocateCurrentGitRepo", func() { + It("returns the path to the root of the CNI git repo", func() { + path, err := testhelpers.LocateCurrentGitRepo() + Expect(err).NotTo(HaveOccurred()) + + AssertItIsTheCNIRepoRoot(path) + }) + + Context("when run from a different directory", func() { + BeforeEach(func() { + os.Chdir("..") + }) + + It("still finds the CNI repo root", func() { + path, err := testhelpers.LocateCurrentGitRepo() + Expect(err).NotTo(HaveOccurred()) + + AssertItIsTheCNIRepoRoot(path) + }) + }) +}) + +func AssertItIsTheCNIRepoRoot(path string) { + Expect(path).To(BeADirectory()) + files, err := ioutil.ReadDir(path) + Expect(err).NotTo(HaveOccurred()) + + names := []string{} + for _, file := range files { + names = append(names, file.Name()) + } + + Expect(names).To(ContainElement("SPEC.md")) + Expect(names).To(ContainElement("libcni")) + Expect(names).To(ContainElement("cnitool")) +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 00000000..efe8ea87 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,61 @@ +// 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 version + +import ( + "fmt" + + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/types/020" + "github.com/containernetworking/cni/pkg/types/current" +) + +// Current reports the version of the CNI spec implemented by this library +func Current() string { + return "0.3.1" +} + +// Legacy PluginInfo describes a plugin that is backwards compatible with the +// CNI spec version 0.1.0. In particular, a runtime compiled against the 0.1.0 +// library ought to work correctly with a plugin that reports support for +// Legacy versions. +// +// Any future CNI spec versions which meet this definition should be added to +// this list. +var Legacy = PluginSupports("0.1.0", "0.2.0") +var All = PluginSupports("0.1.0", "0.2.0", "0.3.0", "0.3.1") + +var resultFactories = []struct { + supportedVersions []string + newResult types.ResultFactoryFunc +}{ + {current.SupportedVersions, current.NewResult}, + {types020.SupportedVersions, types020.NewResult}, +} + +// Finds a Result object matching the requested version (if any) and asks +// that object to parse the plugin result, returning an error if parsing failed. +func NewResult(version string, resultBytes []byte) (types.Result, error) { + reconciler := &Reconciler{} + for _, resultFactory := range resultFactories { + err := reconciler.CheckRaw(version, resultFactory.supportedVersions) + if err == nil { + // Result supports this version + return resultFactory.newResult(resultBytes) + } + } + + return nil, fmt.Errorf("unsupported CNI result version %q", version) +} diff --git a/pkg/version/version_suite_test.go b/pkg/version/version_suite_test.go new file mode 100644 index 00000000..25d503c8 --- /dev/null +++ b/pkg/version/version_suite_test.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 version_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "testing" +) + +func TestVersion(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Version Suite") +}