diff --git a/SPEC.md b/SPEC.md index 52bfefe2..2bf4ef73 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,6 +8,7 @@ - [Configuration format](#configuration-format) - [Plugin configuration objects:](#plugin-configuration-objects) - [Example configuration](#example-configuration) + - [Version considerations](#version-considerations) - [Section 2: Execution Protocol](#section-2-execution-protocol) - [Overview](#overview-1) - [Parameters](#parameters) @@ -107,6 +108,7 @@ require this. A network configuration consists of a JSON object with the following keys: - `cniVersion` (string): [Semantic Version 2.0](https://semver.org) of CNI specification to which this configuration list and all the individual configurations conform. Currently "1.1.0" +- `cniVersions` (string list): List of all CNI versions which this configuration supports. See [version selection](#version-selection) below. - `name` (string): Network name. This should be unique across all network configurations on a host (or other administrative domain). Must start with an alphanumeric character, optionally followed by any combination of one or more alphanumeric characters, underscore, dot (.) or hyphen (-). - `disableCheck` (boolean): Either `true` or `false`. If `disableCheck` is `true`, runtimes must not call `CHECK` for this network configuration list. This allows an administrator to prevent `CHECK`ing where a combination of plugins is known to return spurious errors. - `plugins` (list): A list of CNI plugins and their configuration, which is a list of plugin configuration objects. @@ -147,6 +149,7 @@ Plugins may define additional fields that they accept and may generate an error ```jsonc { "cniVersion": "1.1.0", + "cniVersions": ["0.3.1", "0.4.0", "1.0.0", "1.1.0"], "name": "dbnet", "plugins": [ { @@ -185,6 +188,15 @@ Plugins may define additional fields that they accept and may generate an error } ``` +### Version considerations + +CNI runtimes, plugins, and network configurations may support multiple CNI specification versions independently. Plugins indicate their set of supported versions through the VERSION command, while network configurations indicate their set of supported versions through the `cniVersion` and `cniVersions` fields. + +CNI runtimes MUST select the highest supported version from the set of network configuration versions given by the `cniVersion` and `cniVersions` fields. Runtimes MAY consider the set of supported plugin versions as reported by the VERSION command when determining available versions. + + +The CNI protocol follows Semantic Versioning principles, so the configuration format MUST remain backwards and forwards compatible within major versions. + ## Section 2: Execution Protocol ### Overview @@ -471,7 +483,7 @@ The network configuration format (which is a list of plugin configurations to ex The request configuration for a single plugin invocation is also JSON. It consists of the plugin configuration, primarily unchanged except for the specified additions and removals. The following fields are always to be inserted into the request configuration by the runtime: -- `cniVersion`: taken from the `cniVersion` field of the network configuration +- `cniVersion`: the protocol version selected by the runtime - the string "1.1.0" - `name`: taken from the `name` field of the network configuration @@ -596,7 +608,7 @@ Example: Plugins should output a JSON object with the following keys if they encounter an error: -- `cniVersion`: The same value as provided by the configuration +- `cniVersion`: The protocol version in use - "1.1.0" - `code`: A numeric error code, see below for reserved codes. - `msg`: A short message characterizing the error. - `details`: A longer message describing the error. diff --git a/go.mod b/go.mod index 25767acd..bb51abe0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/containernetworking/cni go 1.18 require ( + github.com/Masterminds/semver/v3 v3.2.1 github.com/onsi/ginkgo/v2 v2.13.2 github.com/onsi/gomega v1.30.0 github.com/vishvananda/netns v0.0.4 diff --git a/go.sum b/go.sum index 960e18ea..436f270f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= diff --git a/libcni/conf.go b/libcni/conf.go index 4be29311..6c5d99de 100644 --- a/libcni/conf.go +++ b/libcni/conf.go @@ -23,7 +23,10 @@ import ( "sort" "strings" + "github.com/Masterminds/semver/v3" + "github.com/containernetworking/cni/pkg/types" + "github.com/containernetworking/cni/pkg/version" ) type NotFoundError struct { @@ -86,6 +89,47 @@ func ConfListFromBytes(bytes []byte) (*NetworkConfigList, error) { } } + rawVersions, ok := rawList["cniVersions"] + if ok { + // Parse the current package CNI version + currentVersion, err := semver.NewVersion(version.Current()) + if err != nil { + panic("CNI version is invalid semver!") + } + + rvs, ok := rawVersions.([]interface{}) + if !ok { + return nil, fmt.Errorf("error parsing configuration list: invalid type for cniVersions: %T", rvs) + } + vs := make([]*semver.Version, 0, len(rvs)) + for i, rv := range rvs { + v, ok := rv.(string) + if !ok { + return nil, fmt.Errorf("error parsing configuration list: invalid type for cniVersions index %d: %T", i, rv) + } + if v, err := semver.NewVersion(v); err != nil { + return nil, fmt.Errorf("error parsing configuration list: invalid cniVersions entry %s at index %d: %w", v, i, err) + } else if !v.GreaterThan(currentVersion) { + // Skip versions "greater" than this implementation of the spec + vs = append(vs, v) + } + } + + // if cniVersion was already set, append it to the list for sorting. + if cniVersion != "" { + if v, err := semver.NewVersion(cniVersion); err != nil { + return nil, fmt.Errorf("error parsing configuration list: invalid cniVersion %s: %w", cniVersion, err) + } else if !v.GreaterThan(currentVersion) { + // ignore any versions higher than the current implemented spec version + vs = append(vs, v) + } + } + sort.Sort(semver.Collection(vs)) + if len(vs) > 0 { + cniVersion = vs[len(vs)-1].String() + } + } + disableCheck := false if rawDisableCheck, ok := rawList["disableCheck"]; ok { disableCheck, ok = rawDisableCheck.(bool) diff --git a/libcni/conf_test.go b/libcni/conf_test.go index 74fb0dc2..ea7104f2 100644 --- a/libcni/conf_test.go +++ b/libcni/conf_test.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "path/filepath" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -504,6 +505,53 @@ var _ = Describe("Loading configuration from disk", func() { }) }) +var _ = Describe("ConfListFromBytes", func() { + Describe("Version selection", func() { + makeConfig := func(versions ...string) []byte { + // ugly fake json encoding, but whatever + vs := []string{} + for _, v := range versions { + vs = append(vs, fmt.Sprintf(`"%s"`, v)) + } + return []byte(fmt.Sprintf(`{"name": "test", "cniVersions": [%s], "plugins": [{"type": "foo"}]}`, strings.Join(vs, ","))) + } + It("correctly selects the maximum version", func() { + conf, err := libcni.ConfListFromBytes(makeConfig("1.1.0", "0.4.0", "1.0.0")) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.CNIVersion).To(Equal("1.1.0")) + }) + + It("selects the highest version supported by libcni", func() { + conf, err := libcni.ConfListFromBytes(makeConfig("99.0.0", "1.1.0", "0.4.0", "1.0.0")) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.CNIVersion).To(Equal("1.1.0")) + }) + + It("fails when invalid versions are specified", func() { + _, err := libcni.ConfListFromBytes(makeConfig("1.1.0", "0.4.0", "1.0.f")) + Expect(err).To(HaveOccurred()) + }) + + It("falls back to cniVersion", func() { + conf, err := libcni.ConfListFromBytes([]byte(`{"name": "test", "cniVersion": "1.2.3", "plugins": [{"type": "foo"}]}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.CNIVersion).To(Equal("1.2.3")) + }) + + It("merges cniVersions and cniVersion", func() { + conf, err := libcni.ConfListFromBytes([]byte(`{"name": "test", "cniVersion": "1.0.0", "cniVersions": ["0.1.0", "0.4.0"], "plugins": [{"type": "foo"}]}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.CNIVersion).To(Equal("1.0.0")) + }) + + It("handles an empty cniVersions array", func() { + conf, err := libcni.ConfListFromBytes([]byte(`{"name": "test", "cniVersions": [], "plugins": [{"type": "foo"}]}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(conf.CNIVersion).To(Equal("")) + }) + }) +}) + var _ = Describe("ConfListFromConf", func() { var testNetConfig *libcni.NetworkConfig