From 94bc4f95c025e9324dcd8735ef9754896ee0e361 Mon Sep 17 00:00:00 2001 From: Seth Hoenig Date: Thu, 1 Dec 2022 14:17:05 -0600 Subject: [PATCH] fingerprint: add fingerprinting for CNI plugins presense and version This PR adds a fingerprinter to set the attribute "plugins.cni.version." => "" for each CNI plugin in .cni_path (/opt/cni/bin by default). --- .changelog/15452.txt | 3 + client/fingerprint/cni.go | 8 +- client/fingerprint/fingerprint.go | 23 ++-- client/fingerprint/plugins_cni.go | 114 ++++++++++++++++++++ client/fingerprint/plugins_cni_test.go | 87 +++++++++++++++ client/fingerprint/test_fixtures/cni/bridge | 3 + client/fingerprint/test_fixtures/cni/custom | 4 + 7 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 .changelog/15452.txt create mode 100644 client/fingerprint/plugins_cni.go create mode 100644 client/fingerprint/plugins_cni_test.go create mode 100755 client/fingerprint/test_fixtures/cni/bridge create mode 100755 client/fingerprint/test_fixtures/cni/custom diff --git a/.changelog/15452.txt b/.changelog/15452.txt new file mode 100644 index 00000000000..5221daa9143 --- /dev/null +++ b/.changelog/15452.txt @@ -0,0 +1,3 @@ +```release-note:improvement +fingerprint: Detect CNI plugins and set versions as node attributes +``` diff --git a/client/fingerprint/cni.go b/client/fingerprint/cni.go index b4bfff69597..351d7b3d240 100644 --- a/client/fingerprint/cni.go +++ b/client/fingerprint/cni.go @@ -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} } diff --git a/client/fingerprint/fingerprint.go b/client/fingerprint/fingerprint.go index a12ea98f442..39c8dcba964 100644 --- a/client/fingerprint/fingerprint.go +++ b/client/fingerprint/fingerprint.go @@ -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. diff --git a/client/fingerprint/plugins_cni.go b/client/fingerprint/plugins_cni.go new file mode 100644 index 00000000000..e1eb89d3e8f --- /dev/null +++ b/client/fingerprint/plugins_cni.go @@ -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 + // 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 +} diff --git a/client/fingerprint/plugins_cni_test.go b/client/fingerprint/plugins_cni_test.go new file mode 100644 index 00000000000..4a03baec0a0 --- /dev/null +++ b/client/fingerprint/plugins_cni_test.go @@ -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) +} diff --git a/client/fingerprint/test_fixtures/cni/bridge b/client/fingerprint/test_fixtures/cni/bridge new file mode 100755 index 00000000000..0b7f14f7f50 --- /dev/null +++ b/client/fingerprint/test_fixtures/cni/bridge @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "CNI bridge plugin v1.0.2" diff --git a/client/fingerprint/test_fixtures/cni/custom b/client/fingerprint/test_fixtures/cni/custom new file mode 100755 index 00000000000..d2beee878c9 --- /dev/null +++ b/client/fingerprint/test_fixtures/cni/custom @@ -0,0 +1,4 @@ +#!/bin/sh + +echo "Custom v1.2.3 Plugin" +