fingerprint: add fingerprinting for CNI plugins presense and version (#15452)

This PR adds a fingerprinter to set the attribute
"plugins.cni.version.<name>" => "<version>"

for each CNI plugin in <client>.cni_path (/opt/cni/bin by default).
This commit is contained in:
Seth Hoenig
2022-12-05 14:22:47 -06:00
committed by GitHub
parent 097e65a79e
commit c8e0d5d37d
11 changed files with 234 additions and 20 deletions

View File

@@ -6,16 +6,18 @@ import (
"strings"
"github.com/containernetworking/cni/libcni"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/nomad/structs"
)
// CNIFingerprint creates a fingerprint of the CNI configuration(s) on the
// Nomad client.
type CNIFingerprint struct {
StaticFingerprinter
logger log.Logger
logger hclog.Logger
}
func NewCNIFingerprint(logger log.Logger) Fingerprint {
func NewCNIFingerprint(logger hclog.Logger) Fingerprint {
return &CNIFingerprint{logger: logger}
}

View File

@@ -29,17 +29,18 @@ var (
// hostFingerprinters contains the host fingerprints which are available for a
// given platform.
hostFingerprinters = map[string]Factory{
"arch": NewArchFingerprint,
"consul": NewConsulFingerprint,
"cni": NewCNIFingerprint,
"cpu": NewCPUFingerprint,
"host": NewHostFingerprint,
"memory": NewMemoryFingerprint,
"network": NewNetworkFingerprint,
"nomad": NewNomadFingerprint,
"signal": NewSignalFingerprint,
"storage": NewStorageFingerprint,
"vault": NewVaultFingerprint,
"arch": NewArchFingerprint,
"consul": NewConsulFingerprint,
"cni": NewCNIFingerprint, // networks
"cpu": NewCPUFingerprint,
"host": NewHostFingerprint,
"memory": NewMemoryFingerprint,
"network": NewNetworkFingerprint,
"nomad": NewNomadFingerprint,
"plugins_cni": NewPluginsCNIFingerprint,
"signal": NewSignalFingerprint,
"storage": NewStorageFingerprint,
"vault": NewVaultFingerprint,
}
// envFingerprinters contains the fingerprints that are environment specific.

View File

@@ -0,0 +1,114 @@
package fingerprint
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-version"
)
const (
cniPluginAttribute = "plugins.cni.version"
)
// PluginsCNIFingerprint creates a fingerprint of the CNI plugins present on the
// CNI plugin path specified for the Nomad client.
type PluginsCNIFingerprint struct {
StaticFingerprinter
logger hclog.Logger
lister func(string) ([]os.DirEntry, error)
}
func NewPluginsCNIFingerprint(logger hclog.Logger) Fingerprint {
return &PluginsCNIFingerprint{
logger: logger.Named("cni_plugins"),
lister: os.ReadDir,
}
}
func (f *PluginsCNIFingerprint) Fingerprint(req *FingerprintRequest, resp *FingerprintResponse) error {
cniPath := req.Config.CNIPath
if cniPath == "" {
// this will be set to default by client; if empty then lets just do
// nothing rather than re-assume a default of our own
return nil
}
// list the cni_path directory
entries, err := f.lister(cniPath)
switch {
case err != nil:
f.logger.Warn("failed to read CNI plugins directory", "cni_path", cniPath, "error", err)
resp.Detected = false
return nil
case len(entries) == 0:
f.logger.Debug("no CNI plugins found", "cni_path", cniPath)
resp.Detected = true
return nil
}
// for each file in cni_path, detect executables and try to get their version
for _, entry := range entries {
v, ok := f.detectOne(cniPath, entry)
if ok {
resp.AddAttribute(f.attribute(entry.Name()), v)
}
}
// detection complete, regardless of results
resp.Detected = true
return nil
}
func (f *PluginsCNIFingerprint) attribute(filename string) string {
return fmt.Sprintf("%s.%s", cniPluginAttribute, filename)
}
func (f *PluginsCNIFingerprint) detectOne(cniPath string, entry os.DirEntry) (string, bool) {
fi, err := entry.Info()
if err != nil {
f.logger.Debug("failed to read cni directory entry", "error", err)
return "", false
}
if fi.Mode()&0o111 == 0 {
f.logger.Debug("unexpected non-executable in cni plugin directory", "name", fi.Name())
return "", false // not executable
}
exePath := filepath.Join(cniPath, fi.Name())
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// best effort attempt to get a version from the executable, otherwise
// the version will be "unknown"
// execute with no args; at least container-networking plugins respond with
// version string in this case, which makes Windows support simpler
cmd := exec.CommandContext(ctx, exePath)
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Debug("failed to detect CNI plugin version", "name", fi.Name(), "error", err)
return "unknown", false
}
// try to find semantic versioning string
// e.g.
// /opt/cni/bin/bridge <no args>
// CNI bridge plugin v1.0.0
tokens := strings.Fields(string(output))
for i := len(tokens) - 1; i >= 0; i-- {
token := tokens[i]
if _, parseErr := version.NewSemver(token); parseErr == nil {
return token, true
}
}
f.logger.Debug("failed to parse CNI plugin version", "name", fi.Name())
return "unknown", false
}

View File

@@ -0,0 +1,87 @@
package fingerprint
import (
"os"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/shoenig/test/must"
)
func TestPluginsCNIFingerprint_Fingerprint_present(t *testing.T) {
ci.Parallel(t)
f := NewPluginsCNIFingerprint(testlog.HCLogger(t))
request := &FingerprintRequest{
Config: &config.Config{
CNIPath: "./test_fixtures/cni",
},
}
response := new(FingerprintResponse)
err := f.Fingerprint(request, response)
must.NoError(t, err)
must.True(t, response.Detected)
attrCustom := f.(*PluginsCNIFingerprint).attribute("custom")
attrBridge := f.(*PluginsCNIFingerprint).attribute("bridge")
must.Eq(t, "v1.2.3", response.Attributes[attrCustom])
must.Eq(t, "v1.0.2", response.Attributes[attrBridge])
}
func TestPluginsCNIFingerprint_Fingerprint_absent(t *testing.T) {
ci.Parallel(t)
f := NewPluginsCNIFingerprint(testlog.HCLogger(t))
request := &FingerprintRequest{
Config: &config.Config{
CNIPath: "/does/not/exist",
},
}
response := new(FingerprintResponse)
err := f.Fingerprint(request, response)
must.NoError(t, err)
must.False(t, response.Detected)
attrCustom := f.(*PluginsCNIFingerprint).attribute("custom")
attrBridge := f.(*PluginsCNIFingerprint).attribute("bridge")
must.MapNotContainsKeys(t, response.Attributes, []string{attrCustom, attrBridge})
}
func TestPluginsCNIFingerprint_Fingerprint_empty(t *testing.T) {
ci.Parallel(t)
lister := func(string) ([]os.DirEntry, error) {
// return an empty slice of directory entries
// i.e. no plugins present
return nil, nil
}
f := NewPluginsCNIFingerprint(testlog.HCLogger(t))
f.(*PluginsCNIFingerprint).lister = lister
request := &FingerprintRequest{
Config: &config.Config{
CNIPath: "./test_fixtures/cni",
},
}
response := new(FingerprintResponse)
err := f.Fingerprint(request, response)
must.NoError(t, err)
must.True(t, response.Detected)
}
func TestPluginsCNIFingerprint_Fingerprint_unset(t *testing.T) {
ci.Parallel(t)
f := NewPluginsCNIFingerprint(testlog.HCLogger(t))
request := &FingerprintRequest{
Config: new(config.Config),
}
response := new(FingerprintResponse)
err := f.Fingerprint(request, response)
must.NoError(t, err)
must.False(t, response.Detected)
}

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "CNI bridge plugin v1.0.2"

View File

@@ -0,0 +1,4 @@
#!/bin/sh
echo "Custom v1.2.3 Plugin"