Repository: NetApp/harvest
Pull request #1819 (2023-03-13 19:09:35)

feat: Harvest should fetch auth via script
Fixes: #1805


Patch:
diff --git a/cmd/collectors/rest/rest.go b/cmd/collectors/rest/rest.go
index 4422c59c..57cc3b7e 100644
--- a/cmd/collectors/rest/rest.go
+++ b/cmd/collectors/rest/rest.go
@@ -191,7 +191,7 @@ func (r *Rest) getClient(a *collector.AbstractCollector) (*rest.Client, error) {
 		return nil, errs.New(errs.ErrMissingParam, "addr")
 	}
 	timeout, _ := time.ParseDuration(rest.DefaultTimeout)
-	if client, err = rest.New(*poller, timeout); err != nil {
+	if client, err = rest.New(poller, timeout); err != nil {
 		r.Logger.Error().Err(err).Str("poller", opt.Poller).Msg("error creating new client")
 		os.Exit(1)
 	}
diff --git a/cmd/collectors/storagegrid/rest/client.go b/cmd/collectors/storagegrid/rest/client.go
index cae05ef8..5436a2c8 100644
--- a/cmd/collectors/storagegrid/rest/client.go
+++ b/cmd/collectors/storagegrid/rest/client.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"github.com/hashicorp/go-version"
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/conf"
 	"github.com/netapp/harvest/v2/pkg/errs"
 	"github.com/netapp/harvest/v2/pkg/logging"
@@ -31,7 +32,6 @@ type Client struct {
 	Logger   *logging.Logger
 	baseURL  string
 	Cluster  Cluster
-	password string
 	username string
 	token    string
 	Timeout  time.Duration
@@ -65,14 +65,14 @@ func NewClient(pollerName string, clientTimeout string) (*Client, error) {
 	if err != nil {
 		timeout, _ = time.ParseDuration(DefaultTimeout)
 	}
-	if client, err = New(*poller, timeout); err != nil {
+	if client, err = New(poller, timeout); err != nil {
 		return nil, fmt.Errorf("uanble to create poller [%s]. err: %w", pollerName, err)
 	}
 
 	return client, err
 }
 
-func New(poller conf.Poller, timeout time.Duration) (*Client, error) {
+func New(poller *conf.Poller, timeout time.Duration) (*Client, error) {
 	var (
 		client         Client
 		httpclient     *http.Client
@@ -105,7 +105,7 @@ func New(poller conf.Poller, timeout time.Duration) (*Client, error) {
 
 	// check if a credentials file is being used and if so, parse and use the values from it
 	if poller.CredentialsFile != "" {
-		err := conf.ReadCredentialsFile(poller.CredentialsFile, &poller)
+		err := conf.ReadCredentialsFile(poller.CredentialsFile, poller)
 		if err != nil {
 			client.Logger.Error().
 				Err(err).
@@ -135,9 +135,8 @@ func New(poller conf.Poller, timeout time.Duration) (*Client, error) {
 		}
 	} else {
 		username := poller.Username
-		password := poller.Password
+		password := auth.Get().Password()
 		client.username = username
-		client.password = password
 		if username == "" {
 			return nil, errs.New(errs.ErrMissingParam, "username")
 		} else if password == "" {
@@ -385,11 +384,11 @@ func (c *Client) fetchToken() error {
 	if err != nil {
 		return fmt.Errorf("failed to create auth URL err: %w", err)
 	}
-	auth := authBody{
+	authB := authBody{
 		Username: c.username,
-		Password: c.password,
+		Password: auth.Get().Password(),
 	}
-	postBody, err := json.Marshal(auth)
+	postBody, err := json.Marshal(authB)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/poller/poller.go b/cmd/poller/poller.go
index e016eff3..07d096fe 100644
--- a/cmd/poller/poller.go
+++ b/cmd/poller/poller.go
@@ -48,7 +48,8 @@ import (
 	"github.com/netapp/harvest/v2/cmd/poller/plugin"
 	"github.com/netapp/harvest/v2/cmd/poller/schedule"
 	rest2 "github.com/netapp/harvest/v2/cmd/tools/rest"
-	client "github.com/netapp/harvest/v2/pkg/api/ontapi/zapi"
+	"github.com/netapp/harvest/v2/pkg/api/ontapi/zapi"
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/conf"
 	"github.com/netapp/harvest/v2/pkg/errs"
 	"github.com/netapp/harvest/v2/pkg/logging"
@@ -270,6 +271,9 @@ func (p *Poller) Init() error {
 		}
 	}
 
+	// create a shared auth service that all collectors will use
+	auth.NewCredentials(p.params, logger)
+
 	// initialize our metadata, the metadata will host status of our
 	// collectors and exporters, as well as ping stats to target host
 	p.loadMetadata()
@@ -806,7 +810,7 @@ func Union2(hNode *node.Node, poller *conf.Poller) {
 					case "!!str", "!!bool":
 						newNode.Content = []byte(valNode.Value)
 					case "!!seq":
-						// the poller node that's missing is a sequence so add all the children of the sequence
+						// the poller node that is missing is a sequence so add all the children of the sequence
 						for _, seqNode := range valNode.Content {
 							if seqNode.Tag == "!!str" {
 								newNode.NewChildS(seqNode.Value, seqNode.Value)
@@ -816,6 +820,11 @@ func Union2(hNode *node.Node, poller *conf.Poller) {
 								}
 							}
 						}
+					case "!!map":
+						// the poller node that is missing is a map, add all the children of the map
+						for ci := 0; ci < len(valNode.Content); ci += 2 {
+							newNode.NewChildS(valNode.Content[ci].Value, valNode.Content[ci+1].Value)
+						}
 					}
 					hNode.AddChild(newNode)
 				}
@@ -1204,7 +1213,7 @@ func (p *Poller) negotiateAPI(c conf.Collector, clusterVersion string, checkZAPI
 func doZAPIsExist() error {
 	var (
 		poller     *conf.Poller
-		connection *client.Client
+		connection *zapi.Client
 		err        error
 	)
 
@@ -1212,7 +1221,7 @@ func doZAPIsExist() error {
 	if poller, err = conf.PollerNamed(args.Poller); err != nil {
 		return err
 	}
-	if connection, err = client.New(*poller); err != nil {
+	if connection, err = zapi.New(poller); err != nil {
 		return err
 
 	}
diff --git a/cmd/tools/generate/generate.go b/cmd/tools/generate/generate.go
index c960e43d..b0f6a0a0 100644
--- a/cmd/tools/generate/generate.go
+++ b/cmd/tools/generate/generate.go
@@ -359,7 +359,7 @@ func generateMetrics(path string) {
 	}
 
 	timeout, _ := time.ParseDuration(rest.DefaultTimeout)
-	if restClient, err = rest.New(*poller, timeout); err != nil {
+	if restClient, err = rest.New(poller, timeout); err != nil {
 		fmt.Printf("error creating new client %+v\n", err)
 		os.Exit(1)
 	}
@@ -368,7 +368,7 @@ func generateMetrics(path string) {
 		os.Exit(1)
 	}
 
-	if zapiClient, err = zapi.New(*poller); err != nil {
+	if zapiClient, err = zapi.New(poller); err != nil {
 		fmt.Printf("error creating new client %+v\n", err)
 		os.Exit(1)
 	}
diff --git a/cmd/tools/rest/client.go b/cmd/tools/rest/client.go
index c938ba06..b06805b9 100644
--- a/cmd/tools/rest/client.go
+++ b/cmd/tools/rest/client.go
@@ -7,6 +7,7 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"fmt"
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/conf"
 	"github.com/netapp/harvest/v2/pkg/errs"
 	"github.com/netapp/harvest/v2/pkg/logging"
@@ -33,7 +34,6 @@ type Client struct {
 	Logger   *logging.Logger
 	baseURL  string
 	cluster  Cluster
-	password string
 	username string
 	Timeout  time.Duration
 	logRest  bool // used to log Rest request/response
@@ -47,7 +47,7 @@ type Cluster struct {
 	Version [3]int
 }
 
-func New(poller conf.Poller, timeout time.Duration) (*Client, error) {
+func New(poller *conf.Poller, timeout time.Duration) (*Client, error) {
 	var (
 		client         Client
 		httpclient     *http.Client
@@ -83,7 +83,7 @@ func New(poller conf.Poller, timeout time.Duration) (*Client, error) {
 
 	// check if a credentials file is being used and if so, parse and use the values from it
 	if poller.CredentialsFile != "" {
-		err := conf.ReadCredentialsFile(poller.CredentialsFile, &poller)
+		err := conf.ReadCredentialsFile(poller.CredentialsFile, poller)
 		if err != nil {
 			client.Logger.Error().
 				Err(err).
@@ -133,9 +133,8 @@ func New(poller conf.Poller, timeout time.Duration) (*Client, error) {
 		}
 	} else {
 		username := poller.Username
-		password := poller.Password
+		password := auth.Get().Password()
 		client.username = username
-		client.password = password
 		if username == "" {
 			return nil, errs.New(errs.ErrMissingParam, "username")
 		} else if password == "" {
@@ -195,7 +194,7 @@ func (c *Client) GetRest(request string) ([]byte, error) {
 	}
 	c.request.Header.Set("accept", "application/json")
 	if c.username != "" {
-		c.request.SetBasicAuth(c.username, c.password)
+		c.request.SetBasicAuth(c.username, auth.Get().Password())
 	}
 	// ensure that we can change body dynamically
 	c.request.GetBody = func() (io.ReadCloser, error) {
@@ -273,13 +272,13 @@ func downloadSwagger(poller *conf.Poller, path string, url string, verbose bool)
 	}
 
 	timeout, _ := time.ParseDuration(DefaultTimeout)
-	if restClient, err = New(*poller, timeout); err != nil {
+	if restClient, err = New(poller, timeout); err != nil {
 		return 0, fmt.Errorf("error creating new client %w", err)
 	}
 
 	downClient := &http.Client{Transport: restClient.client.Transport, Timeout: restClient.client.Timeout}
 	if restClient.username != "" {
-		request.SetBasicAuth(restClient.username, restClient.password)
+		request.SetBasicAuth(restClient.username, auth.Get().Password())
 	}
 	if verbose {
 		requestOut, _ := httputil.DumpRequestOut(request, false)
diff --git a/cmd/tools/rest/rest.go b/cmd/tools/rest/rest.go
index 6ecbd552..0e59df89 100644
--- a/cmd/tools/rest/rest.go
+++ b/cmd/tools/rest/rest.go
@@ -5,7 +5,9 @@ package rest
 import (
 	"encoding/json"
 	"fmt"
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/conf"
+	"github.com/netapp/harvest/v2/pkg/logging"
 	"github.com/netapp/harvest/v2/pkg/util"
 	"github.com/spf13/cobra"
 	"github.com/tidwall/gjson"
@@ -177,7 +179,7 @@ func doData() {
 	}
 
 	timeout, _ := time.ParseDuration(DefaultTimeout)
-	if client, err = New(*poller, timeout); err != nil {
+	if client, err = New(poller, timeout); err != nil {
 		fmt.Printf("error creating new client %+v\n", err)
 		os.Exit(1)
 	}
@@ -219,6 +221,7 @@ func GetPollerAndAddr(pName string) (*conf.Poller, string, error) {
 		fmt.Printf("Poller named [%s] does not have a valid addr=[]\n", pName)
 		return nil, "", err
 	}
+	auth.NewCredentials(poller, logging.Get())
 	return poller, poller.Addr, nil
 }
 
diff --git a/cmd/tools/rest/swag.go b/cmd/tools/rest/swag.go
index d34b223c..d8289845 100644
--- a/cmd/tools/rest/swag.go
+++ b/cmd/tools/rest/swag.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"github.com/bbrks/wrap/v2"
 	"github.com/go-openapi/spec"
+	"github.com/netapp/harvest/v2/pkg/errs"
 	tw "github.com/olekukonko/tablewriter"
 	"gopkg.in/yaml.v3"
 	"html"
@@ -296,6 +297,10 @@ func readSwagger(args Args) (ontap, error) {
 		fmt.Printf("error reading swagger file=[%s] err=%+v\n", args.SwaggerPath, err)
 		return ontap{}, err
 	}
+	if len(contents) == 0 {
+		fmt.Printf("swagger file=[%s] is emptpy. Remove swagger file and try again\n", args.SwaggerPath)
+		return ontap{}, errs.ErrConfig
+	}
 	// read ONTAP swagger yaml and convert to JSON since swagger only has JSON unmarshalling
 	node := make(map[string]interface{})
 	err = yaml.Unmarshal(contents, node)
diff --git a/cmd/tools/zapi/zapi.go b/cmd/tools/zapi/zapi.go
index fbde5de0..dba045f6 100644
--- a/cmd/tools/zapi/zapi.go
+++ b/cmd/tools/zapi/zapi.go
@@ -7,9 +7,11 @@ package zapi
 import (
 	"fmt"
 	client "github.com/netapp/harvest/v2/pkg/api/ontapi/zapi"
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/color"
 	"github.com/netapp/harvest/v2/pkg/conf"
 	"github.com/netapp/harvest/v2/pkg/errs"
+	"github.com/netapp/harvest/v2/pkg/logging"
 	"github.com/netapp/harvest/v2/pkg/tree/node"
 	"github.com/spf13/cobra"
 	"log"
@@ -135,7 +137,8 @@ func doCmd(cmd string) {
 	if poller, err = conf.PollerNamed(args.Poller); err != nil {
 		log.Fatal(err)
 	}
-	if connection, err = client.New(*poller); err != nil {
+	auth.NewCredentials(poller, logging.Get())
+	if connection, err = client.New(poller); err != nil {
 		log.Fatal(err)
 	}
 
@@ -330,7 +333,6 @@ var args = &Args{}
 
 func init() {
 	configPath := conf.GetDefaultHarvestConfigPath()
-
 	Cmd.AddCommand(showCmd, exportCmd)
 	flags := Cmd.PersistentFlags()
 	flags.StringVarP(&args.Poller, "poller", "p", "", "name of poller (cluster), as defined in your harvest config")
diff --git a/pkg/api/ontapi/zapi/client.go b/pkg/api/ontapi/zapi/client.go
index e7f1979d..3b29f322 100644
--- a/pkg/api/ontapi/zapi/client.go
+++ b/pkg/api/ontapi/zapi/client.go
@@ -9,6 +9,7 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"fmt"
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/conf"
 	"github.com/netapp/harvest/v2/pkg/errs"
 	"github.com/netapp/harvest/v2/pkg/logging"
@@ -39,7 +40,7 @@ type Client struct {
 	logZapi    bool            // used to log ZAPI request/response
 }
 
-func New(poller conf.Poller) (*Client, error) {
+func New(poller *conf.Poller) (*Client, error) {
 	var (
 		client         Client
 		httpclient     *http.Client
@@ -100,7 +101,7 @@ func New(poller conf.Poller) (*Client, error) {
 
 	// check if a credentials file is being used and if so, parse and use the values from it
 	if poller.CredentialsFile != "" {
-		err := conf.ReadCredentialsFile(poller.CredentialsFile, &poller)
+		err := conf.ReadCredentialsFile(poller.CredentialsFile, poller)
 		if err != nil {
 			client.Logger.Error().
 				Err(err).
@@ -151,14 +152,14 @@ func New(poller conf.Poller) (*Client, error) {
 			},
 		}
 	} else {
-
+		password := auth.Get().Password()
 		if poller.Username == "" {
 			return nil, errs.New(errs.ErrMissingParam, "username")
-		} else if poller.Password == "" {
+		} else if password == "" {
 			return nil, errs.New(errs.ErrMissingParam, "password")
 		}
 
-		request.SetBasicAuth(poller.Username, poller.Password)
+		request.SetBasicAuth(poller.Username, password)
 		transport = &http.Transport{
 			Proxy:           http.ProxyFromEnvironment,
 			TLSClientConfig: &tls.Config{InsecureSkipVerify: useInsecureTLS}, //nolint:gosec
diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go
new file mode 100644
index 00000000..9fda3653
--- /dev/null
+++ b/pkg/auth/auth.go
@@ -0,0 +1,143 @@
+package auth
+
+import (
+	"bytes"
+	"context"
+	"github.com/netapp/harvest/v2/pkg/conf"
+	"github.com/netapp/harvest/v2/pkg/logging"
+	"os/exec"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+)
+
+const (
+	defaultSchedule = "24h"
+	defaultTimeout  = "10s"
+)
+
+var once sync.Once
+var auth *Credentials
+
+func Get() *Credentials {
+	return auth
+}
+
+func NewCredentials(p *conf.Poller, logger *logging.Logger) {
+	once.Do(func() {
+		if auth == nil {
+			auth = &Credentials{
+				poller: p,
+				logger: logger,
+				authMu: &sync.Mutex{},
+			}
+		}
+	})
+}
+
+type Credentials struct {
+	poller     *conf.Poller
+	nextUpdate time.Time
+	logger     *logging.Logger
+	authMu     *sync.Mutex
+}
+
+func (c *Credentials) Password() string {
+	if c.poller.CredentialsScript.Path == "" {
+		return c.poller.Password
+	}
+	c.authMu.Lock()
+	defer c.authMu.Unlock()
+	if time.Now().After(c.nextUpdate) {
+		c.poller.Password = c.fetchPassword()
+		c.setNextUpdate()
+	}
+	return c.poller.Password
+}
+
+func (c *Credentials) fetchPassword() string {
+	path, err := exec.LookPath(c.poller.CredentialsScript.Path)
+	if err != nil {
+		c.logger.Error().Err(err).Str("path", c.poller.CredentialsScript.Path).Msg("Credentials script lookup failed")
+		return ""
+	}
+	timeout := c.poller.CredentialsScript.Timeout
+	if timeout == "" {
+		timeout = defaultTimeout
+	}
+	duration, err := time.ParseDuration(timeout)
+	if err != nil {
+		c.logger.Error().Err(err).
+			Str("timeout", timeout).
+			Str("default", defaultTimeout).
+			Msg("Failed to parse timeout. Using default")
+		duration, _ = time.ParseDuration(defaultTimeout)
+	}
+	ctx, cancelFunc := context.WithTimeout(context.Background(), duration)
+	defer cancelFunc()
+	cmd := exec.CommandContext(ctx, path, c.poller.Addr, c.poller.Username)
+
+	// Create process group - so we can kill any forked processes
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	cmd.WaitDelay = duration
+	err = cmd.Start()
+	defer func() {
+		if cmd.Process != nil {
+			_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+		}
+	}()
+	if err != nil {
+		c.logger.Error().Err(err).
+			Str("script", path).
+			Str("timeout", duration.String()).
+			Str("stderr", stderr.String()).
+			Str("stdout", stdout.String()).
+			Msg("Failed to start credentials script")
+		return ""
+	}
+	err = cmd.Wait()
+	if err != nil {
+		c.logger.Error().Err(err).
+			Str("script", path).
+			Str("timeout", duration.String()).
+			Str("stderr", stderr.String()).
+			Str("stdout", stdout.String()).
+			Msg("Failed to execute credentials script")
+		return ""
+	}
+	return strings.TrimSpace(stdout.String())
+}
+
+func (c *Credentials) setNextUpdate() {
+	schedule := c.poller.CredentialsScript.Schedule
+	if schedule == "" {
+		schedule = defaultSchedule
+	}
+	if strings.EqualFold(schedule, "always") {
+		return
+	}
+	duration, err := time.ParseDuration(schedule)
+	if err != nil {
+		c.logger.Error().Err(err).
+			Str("schedule", schedule).
+			Str("default", defaultSchedule).
+			Msg("Failed to parse schedule. Using default")
+		duration, _ = time.ParseDuration(defaultSchedule)
+	}
+	c.nextUpdate = time.Now().Add(duration)
+}
+
+// TestNewCredentials is used by testing code to reload auth
+func TestNewCredentials(p *conf.Poller, logger *logging.Logger) *Credentials {
+	auth = &Credentials{
+		poller: p,
+		logger: logger,
+		authMu: &sync.Mutex{},
+	}
+	return auth
+}
diff --git a/pkg/conf/conf.go b/pkg/conf/conf.go
index a05bf513..c36e97a0 100644
--- a/pkg/conf/conf.go
+++ b/pkg/conf/conf.go
@@ -334,32 +334,39 @@ type Collector struct {
 	Templates *[]string `yaml:"-"`
 }
 
+type CredentialsScript struct {
+	Path     string `yaml:"path,omitempty"`
+	Schedule string `yaml:"schedule,omitempty"`
+	Timeout  string `yaml:"timeout,omitempty"`
+}
+
 type Poller struct {
-	Addr            string                `yaml:"addr,omitempty"`
-	APIVersion      string                `yaml:"api_version,omitempty"`
-	APIVfiler       string                `yaml:"api_vfiler,omitempty"`
-	AuthStyle       string                `yaml:"auth_style,omitempty"`
-	CaCertPath      string                `yaml:"ca_cert,omitempty"`
-	ClientTimeout   string                `yaml:"client_timeout,omitempty"`
-	Collectors      []Collector           `yaml:"collectors,omitempty"`
-	CredentialsFile string                `yaml:"credentials_file,omitempty"`
-	Datacenter      string                `yaml:"datacenter,omitempty"`
-	Exporters       []string              `yaml:"exporters,omitempty"`
-	IsKfs           bool                  `yaml:"is_kfs,omitempty"`
-	Labels          *[]*map[string]string `yaml:"labels,omitempty"`
-	LogMaxBytes     int64                 `yaml:"log_max_bytes,omitempty"`
-	LogMaxFiles     int                   `yaml:"log_max_files,omitempty"`
-	LogSet          *[]string             `yaml:"log,omitempty"`
-	Password        string                `yaml:"password,omitempty"`
-	PollerSchedule  string                `yaml:"poller_schedule,omitempty"`
-	SslCert         string                `yaml:"ssl_cert,omitempty"`
-	SslKey          string                `yaml:"ssl_key,omitempty"`
-	TLSMinVersion   string                `yaml:"tls_min_version,omitempty"`
-	UseInsecureTLS  *bool                 `yaml:"use_insecure_tls,omitempty"`
-	Username        string                `yaml:"username,omitempty"`
-	PreferZAPI      bool                  `yaml:"prefer_zapi,omitempty"`
-	promIndex       int
-	Name            string
+	Addr              string                `yaml:"addr,omitempty"`
+	APIVersion        string                `yaml:"api_version,omitempty"`
+	APIVfiler         string                `yaml:"api_vfiler,omitempty"`
+	AuthStyle         string                `yaml:"auth_style,omitempty"`
+	CaCertPath        string                `yaml:"ca_cert,omitempty"`
+	ClientTimeout     string                `yaml:"client_timeout,omitempty"`
+	Collectors        []Collector           `yaml:"collectors,omitempty"`
+	CredentialsFile   string                `yaml:"credentials_file,omitempty"`
+	CredentialsScript CredentialsScript     `yaml:"credentials_script,omitempty"`
+	Datacenter        string                `yaml:"datacenter,omitempty"`
+	Exporters         []string              `yaml:"exporters,omitempty"`
+	IsKfs             bool                  `yaml:"is_kfs,omitempty"`
+	Labels            *[]*map[string]string `yaml:"labels,omitempty"`
+	LogMaxBytes       int64                 `yaml:"log_max_bytes,omitempty"`
+	LogMaxFiles       int                   `yaml:"log_max_files,omitempty"`
+	LogSet            *[]string             `yaml:"log,omitempty"`
+	Password          string                `yaml:"password,omitempty"`
+	PollerSchedule    string                `yaml:"poller_schedule,omitempty"`
+	SslCert           string                `yaml:"ssl_cert,omitempty"`
+	SslKey            string                `yaml:"ssl_key,omitempty"`
+	TLSMinVersion     string                `yaml:"tls_min_version,omitempty"`
+	UseInsecureTLS    *bool                 `yaml:"use_insecure_tls,omitempty"`
+	Username          string                `yaml:"username,omitempty"`
+	PreferZAPI        bool                  `yaml:"prefer_zapi,omitempty"`
+	promIndex         int
+	Name              string
 }
 
 func (p *Poller) Union(defaults *Poller) {
@@ -380,7 +387,7 @@ func (p *Poller) Union(defaults *Poller) {
 
 // ZapiPoller creates a poller out of a node, this is a bridge between the node and struct-based code
 // Used by ZAPI based code
-func ZapiPoller(n *node.Node) Poller {
+func ZapiPoller(n *node.Node) *Poller {
 	var p Poller
 
 	if Config.Defaults != nil {
@@ -403,11 +410,8 @@ func ZapiPoller(n *node.Node) Poller {
 		p.Addr = addr
 	}
 	isKfs := n.GetChildContentS("is_kfs")
-	if isKfs == "true" {
-		p.IsKfs = true
-	} else if isKfs == "false" {
-		p.IsKfs = false
-	}
+	p.IsKfs = isKfs == "true"
+
 	if x := n.GetChildContentS("use_insecure_tls"); x != "" {
 		if insecureTLS, err := strconv.ParseBool(x); err == nil {
 			// err can be ignored since conf was already validated
@@ -435,6 +439,11 @@ func ZapiPoller(n *node.Node) Poller {
 	if credentialsFile := n.GetChildContentS("credentials_file"); credentialsFile != "" {
 		p.CredentialsFile = credentialsFile
 	}
+	if credentialsScriptNode := n.GetChildS("credentials_script"); credentialsScriptNode != nil {
+		p.CredentialsScript.Path = credentialsScriptNode.GetChildContentS("path")
+		p.CredentialsScript.Schedule = credentialsScriptNode.GetChildContentS("schedule")
+		p.CredentialsScript.Timeout = credentialsScriptNode.GetChildContentS("timeout")
+	}
 	if clientTimeout := n.GetChildContentS("client_timeout"); clientTimeout != "" {
 		p.ClientTimeout = clientTimeout
 	} else {
@@ -449,7 +458,7 @@ func ZapiPoller(n *node.Node) Poller {
 		names := logSet.GetAllChildNamesS()
 		p.LogSet = &names
 	}
-	return p
+	return &p
 }
 
 type Exporter struct {


Test patch:
diff --git a/pkg/api/ontapi/zapi/client_test.go b/pkg/api/ontapi/zapi/client_test.go
index 6971c62f..47d95d44 100644
--- a/pkg/api/ontapi/zapi/client_test.go
+++ b/pkg/api/ontapi/zapi/client_test.go
@@ -1,7 +1,9 @@
 package zapi
 
 import (
+	"github.com/netapp/harvest/v2/pkg/auth"
 	"github.com/netapp/harvest/v2/pkg/conf"
+	"github.com/netapp/harvest/v2/pkg/logging"
 	"github.com/netapp/harvest/v2/pkg/tree/node"
 	"testing"
 	"time"
@@ -51,7 +53,9 @@ func TestNew(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			_, err := New(conf.ZapiPoller(tt.config))
+			poller := conf.ZapiPoller(tt.config)
+			auth.TestNewCredentials(poller, logging.Get())
+			_, err := New(poller)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
 				return