diff --git a/.changelog/15452.txt b/.changelog/15452.txt new file mode 100644 index 000000000..5221daa91 --- /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/api/go.mod b/api/go.mod index e40f6b16c..756736310 100644 --- a/api/go.mod +++ b/api/go.mod @@ -11,7 +11,7 @@ require ( github.com/kr/pretty v0.3.1 github.com/mitchellh/go-testing-interface v1.14.1 github.com/mitchellh/mapstructure v1.5.0 - github.com/shoenig/test v0.4.5 + github.com/shoenig/test v0.4.6 github.com/stretchr/testify v1.8.1 ) diff --git a/api/go.sum b/api/go.sum index e36a08f4a..92003c4e2 100644 --- a/api/go.sum +++ b/api/go.sum @@ -29,8 +29,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/shoenig/test v0.4.5 h1:Qb3JfRzzDVTnfQVMeYJm+bsoPlgqVjgawc2lq95ge8Q= -github.com/shoenig/test v0.4.5/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0= +github.com/shoenig/test v0.4.6 h1:S1pAVs5L1TSRen3N1YQNtBZIh9Z6d1PyQSUDUweMTqk= +github.com/shoenig/test v0.4.6/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/client/fingerprint/cni.go b/client/fingerprint/cni.go index b4bfff695..351d7b3d2 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 a12ea98f4..39c8dcba9 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 000000000..e1eb89d3e --- /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 000000000..4a03baec0 --- /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 000000000..0b7f14f7f --- /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 000000000..d2beee878 --- /dev/null +++ b/client/fingerprint/test_fixtures/cni/custom @@ -0,0 +1,4 @@ +#!/bin/sh + +echo "Custom v1.2.3 Plugin" + diff --git a/go.mod b/go.mod index 2cc629a9b..7d3ee5ad4 100644 --- a/go.mod +++ b/go.mod @@ -110,7 +110,7 @@ require ( github.com/ryanuber/go-glob v1.0.0 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 github.com/shirou/gopsutil/v3 v3.22.10 - github.com/shoenig/test v0.4.5 + github.com/shoenig/test v0.4.6 github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c github.com/stretchr/testify v1.8.1 github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 diff --git a/go.sum b/go.sum index 78835b23f..9ec5d91fa 100644 --- a/go.sum +++ b/go.sum @@ -1171,8 +1171,8 @@ github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg= github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk= -github.com/shoenig/test v0.4.5 h1:Qb3JfRzzDVTnfQVMeYJm+bsoPlgqVjgawc2lq95ge8Q= -github.com/shoenig/test v0.4.5/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0= +github.com/shoenig/test v0.4.6 h1:S1pAVs5L1TSRen3N1YQNtBZIh9Z6d1PyQSUDUweMTqk= +github.com/shoenig/test v0.4.6/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=