diff --git a/libcni/api.go b/libcni/api.go index 935cfa10..803c882a 100644 --- a/libcni/api.go +++ b/libcni/api.go @@ -89,6 +89,14 @@ type NetworkAttachment struct { CapabilityArgs map[string]interface{} } +type GCAttachment struct { + ContainerID string `json:"containerID"` + IfName string `json:"ifname"` +} +type GCArgs struct { + ValidAttachments []GCAttachment +} + type CNI interface { AddNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) (types.Result, error) CheckNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error @@ -105,6 +113,8 @@ type CNI interface { ValidateNetworkList(ctx context.Context, net *NetworkConfigList) ([]string, error) ValidateNetwork(ctx context.Context, net *NetworkConfig) ([]string, error) + GCNetworkList(ctx context.Context, net *NetworkConfigList, args *GCArgs) error + GetCachedAttachments(containerID string) ([]*NetworkAttachment, error) } @@ -153,8 +163,11 @@ func buildOneConfig(name, cniVersion string, orig *NetworkConfig, prevResult typ if err != nil { return nil, err } + if rt != nil { + return injectRuntimeConfig(orig, rt) + } - return injectRuntimeConfig(orig, rt) + return orig, nil } // This function takes a libcni RuntimeConf structure and injects values into @@ -741,6 +754,81 @@ func (c *CNIConfig) GetVersionInfo(ctx context.Context, pluginType string) (vers return invoke.GetVersionInfo(ctx, pluginPath, c.exec) } +// GCNetworkList will do two things +// - dump the list of cached attachments, and issue deletes as necessary +// - issue a GC to the underlying plugins (if the version is high enough) +func (c *CNIConfig) GCNetworkList(ctx context.Context, list *NetworkConfigList, args *GCArgs) error { + // First, get the list of cached attachments + cachedAttachments, err := c.GetCachedAttachments("") + if err != nil { + return nil + } + + validAttachments := make(map[GCAttachment]interface{}, len(args.ValidAttachments)) + for _, a := range args.ValidAttachments { + validAttachments[a] = nil + } + + var errs []error + + for _, cachedAttachment := range cachedAttachments { + if cachedAttachment.Network != list.Name { + continue + } + // we found this attachment + gca := GCAttachment{ + ContainerID: cachedAttachment.ContainerID, + IfName: cachedAttachment.IfName, + } + if _, ok := validAttachments[gca]; ok { + continue + } + // otherwise, this attachment wasn't valid and we should issue a CNI DEL + rt := RuntimeConf{ + ContainerID: cachedAttachment.ContainerID, + NetNS: cachedAttachment.NetNS, + IfName: cachedAttachment.IfName, + Args: cachedAttachment.CniArgs, + CapabilityArgs: cachedAttachment.CapabilityArgs, + } + if err := c.DelNetworkList(ctx, list, &rt); err != nil { + errs = append(errs, fmt.Errorf("failed to delete stale attachment %s %s: %w", rt.ContainerID, rt.IfName, err)) + } + } + + // now, if the version supports it, issue a GC + if gt, _ := version.GreaterThanOrEqualTo(list.CNIVersion, "1.1.0"); gt { + inject := map[string]interface{}{ + "name": list.Name, + "cniVersion": list.CNIVersion, + "cni.dev/valid-attachments": args.ValidAttachments, + } + for _, plugin := range list.Plugins { + // build config here + pluginConfig, err := InjectConf(plugin, inject) + if err != nil { + errs = append(errs, fmt.Errorf("failed to generate configuration to GC plugin %s: %w", plugin.Network.Type, err)) + } + if err := c.gcNetwork(ctx, pluginConfig); err != nil { + errs = append(errs, fmt.Errorf("failed to GC plugin %s: %w", plugin.Network.Type, err)) + } + } + } + + return joinErrors(errs...) +} + +func (c *CNIConfig) gcNetwork(ctx context.Context, net *NetworkConfig) error { + c.ensureExec() + pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path) + if err != nil { + return err + } + args := c.args("GC", &RuntimeConf{}) + + return invoke.ExecPluginWithoutResult(ctx, pluginPath, net.Bytes, args, c.exec) +} + // ===== func (c *CNIConfig) args(action string, rt *RuntimeConf) *invoke.Args { return &invoke.Args{ diff --git a/libcni/api_test.go b/libcni/api_test.go index 36e0e305..1f552325 100644 --- a/libcni/api_test.go +++ b/libcni/api_test.go @@ -39,10 +39,11 @@ import ( ) type pluginInfo struct { - debugFilePath string - debug *noop_debug.Debug - config string - stdinData []byte + debugFilePath string + commandFilePath string + debug *noop_debug.Debug + config string + stdinData []byte } type portMapping struct { @@ -66,6 +67,11 @@ func newPluginInfo(cniVersion, configValue, prevResult string, injectDebugFilePa Expect(debugFile.Close()).To(Succeed()) debugFilePath := debugFile.Name() + commandLog, err := os.CreateTemp("", "cni_debug") + Expect(err).NotTo(HaveOccurred()) + Expect(commandLog.Close()).To(Succeed()) + commandFilePath := commandLog.Name() + debug := &noop_debug.Debug{ ReportResult: result, } @@ -79,6 +85,7 @@ func newPluginInfo(cniVersion, configValue, prevResult string, injectDebugFilePa } if injectDebugFilePath { config += fmt.Sprintf(`, "debugFile": %q`, debugFilePath) + config += fmt.Sprintf(`, "commandLog": %q`, commandFilePath) } if len(capabilities) > 0 { config += `, "capabilities": {` @@ -115,10 +122,11 @@ func newPluginInfo(cniVersion, configValue, prevResult string, injectDebugFilePa Expect(err).NotTo(HaveOccurred()) return pluginInfo{ - debugFilePath: debugFilePath, - debug: debug, - config: config, - stdinData: stdinData, + debugFilePath: debugFilePath, + commandFilePath: commandFilePath, + debug: debug, + config: config, + stdinData: stdinData, } } @@ -1499,6 +1507,79 @@ var _ = Describe("Invoking plugins", func() { Expect(err).To(MatchError("[plugin noop does not support config version \"broken\" plugin noop does not support config version \"broken\" plugin noop does not support config version \"broken\"]")) }) }) + Describe("GCNetworkList", func() { + It("issues a DEL and GC as necessary", func() { + By("doing a CNI ADD") + _, err := cniConfig.AddNetworkList(ctx, netConfigList, runtimeConfig) + Expect(err).NotTo(HaveOccurred()) + + By("Issuing a GC with valid networks") + gcargs := &libcni.GCArgs{ + ValidAttachments: []libcni.GCAttachment{{ + ContainerID: runtimeConfig.ContainerID, + IfName: runtimeConfig.IfName, + }}, + } + err = cniConfig.GCNetworkList(ctx, netConfigList, gcargs) + Expect(err).NotTo(HaveOccurred()) + + By("Issuing a GC with no valid networks") + gcargs.ValidAttachments = nil + err = cniConfig.GCNetworkList(ctx, netConfigList, gcargs) + Expect(err).NotTo(HaveOccurred()) + + commands, err := noop_debug.ReadCommandLog(plugins[0].commandFilePath) + Expect(err).NotTo(HaveOccurred()) + Expect(commands).To(HaveLen(4)) + + validations := []struct { + name string + fn func(entry noop_debug.CmdLogEntry) + }{ + { + name: "ADD", + fn: func(entry noop_debug.CmdLogEntry) { + Expect(entry.CmdArgs.ContainerID).To(Equal(runtimeConfig.ContainerID)) + Expect(entry.CmdArgs.IfName).To(Equal(runtimeConfig.IfName)) + }, + }, + { + name: "GC", + fn: func(entry noop_debug.CmdLogEntry) { + var conf struct { + Attachments []map[string]string `json:"cni.dev/valid-attachments"` + } + err = json.Unmarshal(entry.CmdArgs.StdinData, &conf) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.Attachments).To(HaveLen(1)) + Expect(conf.Attachments[0]).To(Equal(map[string]string{"containerID": runtimeConfig.ContainerID, "ifname": runtimeConfig.IfName})) + }, + }, + { + name: "DEL", + fn: func(entry noop_debug.CmdLogEntry) { + Expect(entry.CmdArgs.ContainerID).To(Equal(runtimeConfig.ContainerID)) + Expect(entry.CmdArgs.IfName).To(Equal(runtimeConfig.IfName)) + }, + }, + { + name: "GC", + fn: func(entry noop_debug.CmdLogEntry) { + var conf struct { + Attachments []map[string]string `json:"cni.dev/valid-attachments"` + } + err = json.Unmarshal(entry.CmdArgs.StdinData, &conf) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.Attachments).To(BeEmpty()) + }, + }, + } + for i, c := range validations { + Expect(commands[i].Command).To(Equal(c.name)) + c.fn(commands[i]) + } + }) + }) }) Describe("Invoking a sleep plugin", func() { diff --git a/libcni/multierror.go b/libcni/multierror.go new file mode 100644 index 00000000..100fb839 --- /dev/null +++ b/libcni/multierror.go @@ -0,0 +1,58 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Copyright the 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. +// +// Adapted from errors/join.go from go 1.20 +// This package can be removed once the toolchain is updated to 1.20 + +package libcni + +func joinErrors(errs ...error) error { + n := 0 + for _, err := range errs { + if err != nil { + n++ + } + } + if n == 0 { + return nil + } + e := &multiError{ + errs: make([]error, 0, n), + } + for _, err := range errs { + if err != nil { + e.errs = append(e.errs, err) + } + } + return e +} + +type multiError struct { + errs []error +} + +func (e *multiError) Error() string { + var b []byte + for i, err := range e.errs { + if i > 0 { + b = append(b, '\n') + } + b = append(b, err.Error()...) + } + return string(b) +} diff --git a/pkg/skel/skel.go b/pkg/skel/skel.go index 99b1c149..432923dc 100644 --- a/pkg/skel/skel.go +++ b/pkg/skel/skel.go @@ -60,9 +60,10 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { var cmd, contID, netns, ifName, args, path, netnsOverride string vars := []struct { - name string - val *string - reqForCmd reqForCmdEntry + name string + val *string + reqForCmd reqForCmdEntry + validateFn func(string) *types.Error }{ { "CNI_COMMAND", @@ -71,7 +72,9 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "ADD": true, "CHECK": true, "DEL": true, + "GC": true, }, + nil, }, { "CNI_CONTAINERID", @@ -81,6 +84,7 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "CHECK": true, "DEL": true, }, + utils.ValidateContainerID, }, { "CNI_NETNS", @@ -90,6 +94,7 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "CHECK": true, "DEL": false, }, + nil, }, { "CNI_IFNAME", @@ -99,6 +104,7 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "CHECK": true, "DEL": true, }, + utils.ValidateInterfaceName, }, { "CNI_ARGS", @@ -108,6 +114,7 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "CHECK": false, "DEL": false, }, + nil, }, { "CNI_PATH", @@ -116,7 +123,9 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "ADD": true, "CHECK": true, "DEL": true, + "GC": true, }, + nil, }, { "CNI_NETNS_OVERRIDE", @@ -126,6 +135,7 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { "CHECK": false, "DEL": false, }, + nil, }, } @@ -136,6 +146,10 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { if v.reqForCmd[cmd] || v.name == "CNI_COMMAND" { argsMissing = append(argsMissing, v.name) } + } else if v.reqForCmd[cmd] && v.validateFn != nil { + if err := v.validateFn(*v.val); err != nil { + return "", nil, err + } } } @@ -153,6 +167,12 @@ func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, *types.Error) { return "", nil, types.NewError(types.ErrIOFailure, fmt.Sprintf("error reading from stdin: %v", err), "") } + if cmd != "VERSION" { + if err := validateConfig(stdinData); err != nil { + return "", nil, err + } + } + cmdArgs := &CmdArgs{ ContainerID: contID, Netns: netns, @@ -175,6 +195,10 @@ func (t *dispatcher) checkVersionAndCall(cmdArgs *CmdArgs, pluginVersionInfo ver return types.NewError(types.ErrIncompatibleCNIVersion, "incompatible CNI versions", verErr.Details()) } + if toCall == nil { + return nil + } + if err = toCall(cmdArgs); err != nil { var e *types.Error if errors.As(err, &e) { @@ -203,7 +227,7 @@ func validateConfig(jsonBytes []byte) *types.Error { return nil } -func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo, about string) *types.Error { +func (t *dispatcher) pluginMain(funcs CNIFuncs, versionInfo version.PluginInfo, about string) *types.Error { cmd, cmdArgs, err := t.getCmdArgsFromEnv() if err != nil { // Print the about string to stderr when no command is set @@ -215,21 +239,9 @@ func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, return err } - if cmd != "VERSION" { - if err = validateConfig(cmdArgs.StdinData); err != nil { - return err - } - if err = utils.ValidateContainerID(cmdArgs.ContainerID); err != nil { - return err - } - if err = utils.ValidateInterfaceName(cmdArgs.IfName); err != nil { - return err - } - } - switch cmd { case "ADD": - err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdAdd) + err = t.checkVersionAndCall(cmdArgs, versionInfo, funcs.Add) if err != nil { return err } @@ -256,7 +268,7 @@ func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, if err != nil { return types.NewError(types.ErrDecodingFailure, err.Error(), "") } else if gtet { - if err := t.checkVersionAndCall(cmdArgs, versionInfo, cmdCheck); err != nil { + if err := t.checkVersionAndCall(cmdArgs, versionInfo, funcs.Check); err != nil { return err } return nil @@ -264,7 +276,7 @@ func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, } return types.NewError(types.ErrIncompatibleCNIVersion, "plugin version does not allow CHECK", "") case "DEL": - err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdDel) + err = t.checkVersionAndCall(cmdArgs, versionInfo, funcs.Del) if err != nil { return err } @@ -276,6 +288,28 @@ func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, return types.NewError(types.ErrInvalidNetNS, "plugin's netns and netns from CNI_NETNS should not be the same", "") } } + case "GC": + configVersion, err := t.ConfVersionDecoder.Decode(cmdArgs.StdinData) + if err != nil { + return types.NewError(types.ErrDecodingFailure, err.Error(), "") + } + if gtet, err := version.GreaterThanOrEqualTo(configVersion, "1.1.0"); err != nil { + return types.NewError(types.ErrDecodingFailure, err.Error(), "") + } else if !gtet { + return types.NewError(types.ErrIncompatibleCNIVersion, "config version does not allow GC", "") + } + for _, pluginVersion := range versionInfo.SupportedVersions() { + gtet, err := version.GreaterThanOrEqualTo(pluginVersion, configVersion) + if err != nil { + return types.NewError(types.ErrDecodingFailure, err.Error(), "") + } else if gtet { + if err := t.checkVersionAndCall(cmdArgs, versionInfo, funcs.GC); err != nil { + return err + } + return nil + } + } + return types.NewError(types.ErrIncompatibleCNIVersion, "plugin version does not allow GC", "") case "VERSION": if err := versionInfo.Encode(t.Stdout); err != nil { return types.NewError(types.ErrIOFailure, err.Error(), "") @@ -299,13 +333,62 @@ func (t *dispatcher) pluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, // // To let this package automatically handle errors and call os.Exit(1) for you, // use PluginMain() instead. +// +// Deprecated: Use github.com/containernetworking/cni/pkg/skel.PluginMainFuncsWithError instead. func PluginMainWithError(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo, about string) *types.Error { + return PluginMainFuncsWithError(CNIFuncs{Add: cmdAdd, Check: cmdCheck, Del: cmdDel}, versionInfo, about) +} + +// CNIFuncs contains a group of callback command funcs to be passed in as +// parameters to the core "main" for a plugin. +type CNIFuncs struct { + Add func(_ *CmdArgs) error + Del func(_ *CmdArgs) error + Check func(_ *CmdArgs) error + GC func(_ *CmdArgs) error +} + +// PluginMainFuncsWithError is the core "main" for a plugin. It accepts +// callback functions defined within CNIFuncs 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 PluginMainFuncs() instead. +func PluginMainFuncsWithError(funcs CNIFuncs, versionInfo version.PluginInfo, about string) *types.Error { return (&dispatcher{ Getenv: os.Getenv, Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, - }).pluginMain(cmdAdd, cmdCheck, cmdDel, versionInfo, about) + }).pluginMain(funcs, versionInfo, about) +} + +// PluginMainFuncs is the core "main" for a plugin which includes automatic error handling. +// This is a newer alternative func to PluginMain which abstracts CNI commands within a +// CNIFuncs interface. +// +// The caller must also specify what CNI spec versions the plugin supports. +// +// The caller can specify an "about" string, which is printed on stderr +// when no CNI_COMMAND is specified. The recommended output is "CNI plugin v" +// +// When an error occurs in any func in CNIFuncs, PluginMainFuncs will print the error +// as JSON to stdout and call os.Exit(1). +// +// To have more control over error handling, use PluginMainFuncsWithError() instead. +func PluginMainFuncs(funcs CNIFuncs, versionInfo version.PluginInfo, about string) { + if e := PluginMainFuncsWithError(funcs, versionInfo, about); e != nil { + if err := e.Print(); err != nil { + log.Print("Error writing error JSON to stdout: ", err) + } + os.Exit(1) + } } // PluginMain is the core "main" for a plugin which includes automatic error handling. @@ -319,6 +402,8 @@ func PluginMainWithError(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versio // as JSON to stdout and call os.Exit(1). // // To have more control over error handling, use PluginMainWithError() instead. +// +// Deprecated: Use github.com/containernetworking/cni/pkg/skel.PluginMainFuncs instead. func PluginMain(cmdAdd, cmdCheck, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo, about string) { if e := PluginMainWithError(cmdAdd, cmdCheck, cmdDel, versionInfo, about); e != nil { if err := e.Print(); err != nil { diff --git a/pkg/skel/skel_test.go b/pkg/skel/skel_test.go index 518c4691..3c4f864f 100644 --- a/pkg/skel/skel_test.go +++ b/pkg/skel/skel_test.go @@ -45,13 +45,14 @@ func (c *fakeCmd) Func(args *CmdArgs) error { var _ = Describe("dispatching to the correct callback", func() { var ( - environment map[string]string - stdinData string - stdout, stderr *bytes.Buffer - cmdAdd, cmdCheck, cmdDel *fakeCmd - dispatch *dispatcher - expectedCmdArgs *CmdArgs - versionInfo version.PluginInfo + environment map[string]string + stdinData string + stdout, stderr *bytes.Buffer + cmdAdd, cmdCheck, cmdDel, cmdGC *fakeCmd + dispatch *dispatcher + expectedCmdArgs *CmdArgs + versionInfo version.PluginInfo + funcs CNIFuncs ) BeforeEach(func() { @@ -77,6 +78,14 @@ var _ = Describe("dispatching to the correct callback", func() { cmdAdd = &fakeCmd{} cmdCheck = &fakeCmd{} cmdDel = &fakeCmd{} + cmdGC = &fakeCmd{} + funcs = CNIFuncs{ + Add: cmdAdd.Func, + Del: cmdDel.Func, + Check: cmdCheck.Func, + GC: cmdGC.Func, + } + expectedCmdArgs = &CmdArgs{ ContainerID: "some-container-id", Netns: "/some/netns/path", @@ -90,7 +99,7 @@ var _ = Describe("dispatching to the correct callback", func() { envVarChecker := func(envVar string, isRequired bool) { delete(environment, envVar) - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") if isRequired { Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -102,8 +111,17 @@ var _ = Describe("dispatching to the correct callback", func() { } Context("when the CNI_COMMAND is ADD", func() { + expectedCmdArgs = &CmdArgs{ + ContainerID: "some-container-id", + Netns: "/some/netns/path", + IfName: "eth0", + Args: "some;extra;args", + Path: "/some/cni/path", + StdinData: []byte(stdinData), + } + It("extracts env vars and stdin data and calls cmdAdd", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(1)) @@ -114,7 +132,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("returns an error when containerID has invalid characters", func() { environment["CNI_CONTAINERID"] = "some-%%container-id" - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -127,7 +145,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("interface name is too long", func() { environment["CNI_IFNAME"] = "1234567890123456" - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -139,7 +157,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("interface name is .", func() { environment["CNI_IFNAME"] = "." - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -151,7 +169,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("interface name is ..", func() { environment["CNI_IFNAME"] = ".." - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -163,7 +181,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("interface name contains invalid characters /", func() { environment["CNI_IFNAME"] = "test/test" - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -175,7 +193,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("interface name contains invalid characters :", func() { environment["CNI_IFNAME"] = "test:test" - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -187,7 +205,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("interface name contains invalid characters whitespace", func() { environment["CNI_IFNAME"] = "test test" - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -198,7 +216,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("does not call cmdCheck or cmdDel", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdCheck.CallCount).To(Equal(0)) @@ -222,7 +240,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("reports that all of them are missing, not just the first", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ @@ -244,7 +262,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("infers the config is 0.1.0 and calls the cmdAdd callback", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(1)) @@ -258,14 +276,14 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("immediately returns a useful error", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, 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() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) Expect(cmdCheck.CallCount).To(Equal(0)) @@ -281,7 +299,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("extracts env vars and stdin data and calls cmdCheck", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -291,7 +309,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("does not call cmdAdd or cmdDel", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -315,7 +333,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("reports that all of them are missing, not just the first", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(&types.Error{ @@ -328,7 +346,7 @@ var _ = Describe("dispatching to the correct callback", func() { Context("when cniVersion is less than 0.4.0", func() { It("immediately returns a useful error", func() { dispatch.Stdin = strings.NewReader(`{ "name": "skel-test", "cniVersion": "0.3.0", "some": "config" }`) - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, 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("config version does not allow CHECK")) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -341,7 +359,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("immediately returns a useful error", func() { dispatch.Stdin = strings.NewReader(`{ "name": "skel-test", "cniVersion": "0.4.0", "some": "config" }`) versionInfo = version.PluginSupports("0.1.0", "0.2.0", "0.3.0") - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, 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("plugin version does not allow CHECK")) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -354,7 +372,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("immediately returns a useful error", func() { dispatch.Stdin = strings.NewReader(`{ "cniVersion": "adsfsadf", "some": "config", "name": "test" }`) versionInfo = version.PluginSupports("0.1.0", "0.2.0", "0.3.0") - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err.Code).To(Equal(types.ErrDecodingFailure)) Expect(cmdAdd.CallCount).To(Equal(0)) Expect(cmdCheck.CallCount).To(Equal(0)) @@ -366,7 +384,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("immediately returns invalid network config", func() { dispatch.Stdin = strings.NewReader(`{ "cniVersion": "0.4.0", "some": "config", "name": "te%%st" }`) versionInfo = version.PluginSupports("0.1.0", "0.2.0", "0.3.0", "0.4.0") - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err.Code).To(Equal(types.ErrInvalidNetworkConfig)) Expect(cmdAdd.CallCount).To(Equal(0)) Expect(cmdCheck.CallCount).To(Equal(0)) @@ -378,7 +396,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("immediately returns a useful error", func() { dispatch.Stdin = strings.NewReader(`{ "cniVersion": "0.4.0", "some": "config", "name": "test" }`) versionInfo = version.PluginSupports("0.1.0", "0.2.0", "adsfasdf") - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err.Code).To(Equal(types.ErrDecodingFailure)) Expect(cmdAdd.CallCount).To(Equal(0)) Expect(cmdCheck.CallCount).To(Equal(0)) @@ -387,13 +405,132 @@ var _ = Describe("dispatching to the correct callback", func() { }) }) + Context("when the CNI_COMMAND is GC", func() { + BeforeEach(func() { + environment["CNI_COMMAND"] = "GC" + delete(environment, "CNI_NETNS") + delete(environment, "CNI_IFNAME") + delete(environment, "CNI_CONTAINERID") + delete(environment, "CNI_ARGS") + + expectedCmdArgs = &CmdArgs{ + Path: "/some/cni/path", + StdinData: []byte(stdinData), + } + + dispatch = &dispatcher{ + Getenv: func(key string) string { + return environment[key] + }, + Stdin: strings.NewReader(stdinData), + Stdout: stdout, + Stderr: stderr, + } + }) + + It("extracts env vars and stdin data and calls cmdGC", func() { + err := dispatch.pluginMain(funcs, versionInfo, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdAdd.CallCount).To(Equal(0)) + Expect(cmdCheck.CallCount).To(Equal(0)) + Expect(cmdDel.CallCount).To(Equal(0)) + Expect(cmdGC.CallCount).To(Equal(1)) + Expect(cmdGC.Received.CmdArgs).To(Equal(expectedCmdArgs)) + }) + + It("does not call cmdAdd or cmdDel", func() { + err := dispatch.pluginMain(funcs, versionInfo, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(cmdAdd.CallCount).To(Equal(0)) + 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", false), + Entry("if name", "CNI_IFNAME", false), + Entry("args", "CNI_ARGS", false), + Entry("path", "CNI_PATH", true), + ) + + Context("when multiple required env vars are missing", func() { + BeforeEach(func() { + delete(environment, "CNI_PATH") + }) + + It("reports that all of them are missing, not just the first", func() { + err := dispatch.pluginMain(funcs, versionInfo, "") + Expect(err).To(HaveOccurred()) + + Expect(err).To(Equal(&types.Error{ + Code: types.ErrInvalidEnvironmentVariables, + Msg: "required env variables [CNI_PATH] missing", + })) + }) + }) + + Context("when cniVersion is less than 1.1.0", func() { + It("immediately returns a useful error", func() { + dispatch.Stdin = strings.NewReader(`{ "name": "skel-test", "cniVersion": "0.3.0", "some": "config" }`) + err := dispatch.pluginMain(funcs, 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("config version does not allow GC")) + Expect(cmdGC.CallCount).To(Equal(0)) + }) + }) + + Context("when plugin does not support 1.1.0", func() { + It("immediately returns a useful error", func() { + dispatch.Stdin = strings.NewReader(`{ "name": "skel-test", "cniVersion": "1.1.0", "some": "config" }`) + versionInfo = version.PluginSupports("0.1.0", "0.2.0", "0.3.0") + err := dispatch.pluginMain(funcs, 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("plugin version does not allow GC")) + Expect(cmdGC.CallCount).To(Equal(0)) + }) + }) + + Context("when the config has a bad version", func() { + It("immediately returns a useful error", func() { + dispatch.Stdin = strings.NewReader(`{ "cniVersion": "adsfsadf", "some": "config", "name": "test" }`) + versionInfo = version.PluginSupports("1.1.0") + err := dispatch.pluginMain(funcs, versionInfo, "") + Expect(err.Code).To(Equal(types.ErrDecodingFailure)) + Expect(cmdGC.CallCount).To(Equal(0)) + }) + }) + + Context("when the config has a bad name", func() { + It("immediately returns invalid network config", func() { + dispatch.Stdin = strings.NewReader(`{ "cniVersion": "0.4.0", "some": "config", "name": "te%%st" }`) + versionInfo = version.PluginSupports("1.1.0") + err := dispatch.pluginMain(funcs, versionInfo, "") + Expect(err.Code).To(Equal(types.ErrInvalidNetworkConfig)) + Expect(cmdGC.CallCount).To(Equal(0)) + }) + }) + + Context("when the plugin has a bad version", func() { + It("immediately returns a useful error", func() { + dispatch.Stdin = strings.NewReader(`{ "cniVersion": "1.1.0", "some": "config", "name": "test" }`) + versionInfo = version.PluginSupports("0.1.0", "0.2.0", "adsfasdf") + err := dispatch.pluginMain(funcs, versionInfo, "") + Expect(err.Code).To(Equal(types.ErrDecodingFailure)) + Expect(cmdGC.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, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdDel.CallCount).To(Equal(1)) @@ -401,7 +538,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("does not call cmdAdd", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -423,7 +560,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("prints the version to stdout", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(stdout).To(MatchJSON(fmt.Sprintf(`{ @@ -433,7 +570,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("does not call cmdAdd or cmdDel", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -453,7 +590,7 @@ var _ = Describe("dispatching to the correct callback", func() { r := &BadReader{} dispatch.Stdin = r - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).NotTo(HaveOccurred()) Expect(r.ReadCount).To(Equal(0)) @@ -470,14 +607,14 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("does not call any cmd callback", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) Expect(cmdDel.CallCount).To(Equal(0)) }) It("returns an error", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(Equal(&types.Error{ Code: types.ErrInvalidEnvironmentVariables, @@ -487,7 +624,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("prints the about string when the command is blank", func() { environment["CNI_COMMAND"] = "" - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "test framework v42") + err := dispatch.pluginMain(funcs, versionInfo, "test framework v42") Expect(err).NotTo(HaveOccurred()) Expect(stderr.String()).To(ContainSubstring("test framework v42")) }) @@ -496,7 +633,7 @@ var _ = Describe("dispatching to the correct callback", func() { Context("when the CNI_COMMAND is missing", func() { It("prints the about string to stderr", func() { environment = map[string]string{} - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "AWESOME PLUGIN") + err := dispatch.pluginMain(funcs, versionInfo, "AWESOME PLUGIN") Expect(err).NotTo(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -507,7 +644,7 @@ var _ = Describe("dispatching to the correct callback", func() { It("fails if there is no about string", func() { environment = map[string]string{} - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) @@ -525,14 +662,14 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("does not call any cmd callback", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(HaveOccurred()) Expect(cmdAdd.CallCount).To(Equal(0)) Expect(cmdDel.CallCount).To(Equal(0)) }) It("wraps and returns the error", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(Equal(&types.Error{ Code: types.ErrIOFailure, @@ -551,7 +688,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("returns the error as-is", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(Equal(&types.Error{ Code: 1234, @@ -566,7 +703,7 @@ var _ = Describe("dispatching to the correct callback", func() { }) It("wraps and returns the error", func() { - err := dispatch.pluginMain(cmdAdd.Func, cmdCheck.Func, cmdDel.Func, versionInfo, "") + err := dispatch.pluginMain(funcs, versionInfo, "") Expect(err).To(Equal(&types.Error{ Code: types.ErrInternal, diff --git a/plugins/test/noop/debug/debug.go b/plugins/test/noop/debug/debug.go index b1b5e3c9..f9d661db 100644 --- a/plugins/test/noop/debug/debug.go +++ b/plugins/test/noop/debug/debug.go @@ -40,6 +40,15 @@ type Debug struct { CmdArgs skel.CmdArgs } +// CmdLogEntry records a single CNI command as well as its args +type CmdLogEntry struct { + Command string + CmdArgs skel.CmdArgs +} + +// CmdLog records a list of CmdLogEntry received by the noop plugin +type CmdLog []CmdLogEntry + // ReadDebug will return a debug file recorded by the noop plugin func ReadDebug(debugFilePath string) (*Debug, error) { debugBytes, err := os.ReadFile(debugFilePath) @@ -70,3 +79,34 @@ func (debug *Debug) WriteDebug(debugFilePath string) error { return nil } + +// WriteCommandLog appends the executed cni command to the record file +func WriteCommandLog(path string, entry CmdLogEntry) error { + buf, err := os.ReadFile(path) + if err != nil { + return err + } + var cmds CmdLog + if len(buf) > 0 { + if err = json.Unmarshal(buf, &cmds); err != nil { + return err + } + } + cmds = append(cmds, entry) + if buf, err = json.Marshal(&cmds); err != nil { + return nil + } + return os.WriteFile(path, buf, 0o644) +} + +func ReadCommandLog(path string) (CmdLog, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cmds CmdLog + if err = json.Unmarshal(buf, &cmds); err != nil { + return nil, err + } + return cmds, nil +} diff --git a/plugins/test/noop/main.go b/plugins/test/noop/main.go index 55272717..a457dc9c 100644 --- a/plugins/test/noop/main.go +++ b/plugins/test/noop/main.go @@ -38,7 +38,8 @@ import ( type NetConf struct { types.NetConf - DebugFile string `json:"debugFile"` + DebugFile string `json:"debugFile"` + CommandLog string `json:"commandLog"` } func loadConf(bytes []byte) (*NetConf, error) { @@ -117,6 +118,17 @@ func debugBehavior(args *skel.CmdArgs, command string) error { return err } + if netConf.CommandLog != "" { + if err = noop_debug.WriteCommandLog( + netConf.CommandLog, + noop_debug.CmdLogEntry{ + Command: command, + CmdArgs: *args, + }); err != nil { + return err + } + } + if debug.ReportStderr != "" { if _, err = os.Stderr.WriteString(debug.ReportStderr); err != nil { return err @@ -196,6 +208,10 @@ func cmdDel(args *skel.CmdArgs) error { return debugBehavior(args, "DEL") } +func cmdGC(args *skel.CmdArgs) error { + return debugBehavior(args, "GC") +} + func saveStdin() ([]byte, error) { // Read original stdin stdinData, err := io.ReadAll(os.Stdin) @@ -227,5 +243,10 @@ func main() { } supportedVersions := debugGetSupportedVersions(stdinData) - skel.PluginMain(cmdAdd, cmdCheck, cmdDel, version.PluginSupports(supportedVersions...), "CNI noop plugin v0.7.0") + skel.PluginMainFuncs(skel.CNIFuncs{ + Add: cmdAdd, + Check: cmdCheck, + Del: cmdDel, + GC: cmdGC, + }, version.PluginSupports(supportedVersions...), "CNI noop plugin v0.7.0") }