Add basic API client (#19)

This commit is contained in:
Jeff Mitchell authored and GitHub committed 2020-05-04 10:58:03 -04:00
1 parent 2f0dd58f11
commit 7f03246c6f
8 files changed
+2143 -11

No files matched your search

+752
View File
@@ -0,0 +1,752 @@
package api
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/hashicorp/errwrap"
cleanhttp "github.com/hashicorp/go-cleanhttp"
retryablehttp "github.com/hashicorp/go-retryablehttp"
rootcerts "github.com/hashicorp/go-rootcerts"
"github.com/hashicorp/watchtower/api/internal/parseutil"
"golang.org/x/time/rate"
)
const EnvWatchtowerAddress = "WATCHTOWER_ADDR"
const EnvWatchtowerCACert = "WATCHTOWER_CACERT"
const EnvWatchtowerCAPath = "WATCHTOWER_CAPATH"
const EnvWatchtowerClientCert = "WATCHTOWER_CLIENT_CERT"
const EnvWatchtowerClientKey = "WATCHTOWER_CLIENT_KEY"
const EnvWatchtowerClientTimeout = "WATCHTOWER_CLIENT_TIMEOUT"
const EnvWatchtowerTLSInsecure = "WATCHTOWER_TLS_INSECURE"
const EnvWatchtowerTLSServerName = "WATCHTOWER_TLS_SERVER_NAME"
const EnvWatchtowerMaxRetries = "WATCHTOWER_MAX_RETRIES"
const EnvWatchtowerToken = "WATCHTOWER_TOKEN"
const EnvWatchtowerRateLimit = "WATCHTOWER_RATE_LIMIT"
const EnvWatchtowerSRVLookup = "WATCHTOWER_SRV_LOOKUP"
const EnvWatchtowerOrg = "WATCHTOWER_ORG"
const EnvWatchtowerProject = "WATCHTOWER_PROJECT"
// Config is used to configure the creation of the client
type Config struct {
// Address is the address of the Watchtower controller. This should be a
// complete URL such as "http://watchtower.example.com". If you need a custom
// SSL cert or want to enable insecure mode, you need to specify a custom
// HttpClient.
Address string
// Token is the client token that reuslts from authentication and can be
// used to make calls into Watchtower
Token string
// HttpClient is the HTTP client to use. Watchtower sets sane defaults for
// the http.Client and its associated http.Transport created in
// DefaultConfig. If you must modify Watchtower's defaults, it is
// suggested that you start with that client and modify as needed rather
// than start with an empty client (or http.DefaultClient).
HttpClient *http.Client
// TLSConfig contains TLS configuration information. After modifying these
// values, ConfigureTLS should be called.
TLSConfig *TLSConfig
// Headers contains extra headers that will be added to any request
Headers http.Header
// MaxRetries controls the maximum number of times to retry when a 5xx
// error occurs. Set to 0 to disable retrying. Defaults to 2 (for a total
// of three tries).
MaxRetries int
// Timeout is for setting custom timeout parameter in the HttpClient
Timeout time.Duration
// If there is an error when creating the configuration, this will be the
// error
Error error
// The Backoff function to use; a default is used if not provided
Backoff retryablehttp.Backoff
// The CheckRetry function to use; a default is used if not provided
CheckRetry retryablehttp.CheckRetry
// Limiter is the rate limiter used by the client.
// If this pointer is nil, then there will be no limit set.
// In contrast, if this pointer is set, even to an empty struct,
// then that limiter will be used. Note that an empty Limiter
// is equivalent blocking all events.
Limiter *rate.Limiter
// OutputCurlString causes the actual request to return an error of type
// *OutputStringError. Type asserting the error message will allow
// fetching a cURL-compatible string for the operation.
//
// Note: It is not thread-safe to set this and make concurrent requests
// with the same client. Cloning a client will not clone this value.
OutputCurlString bool
// SRVLookup enables the client to lookup the host through DNS SRV lookup
SRVLookup bool
// Org is the organization to use if not overridden per-call
Org string
// Project is the project to use if not overridden per-call
Project string
}
// TLSConfig contains the parameters needed to configure TLS on the HTTP client
// used to communicate with Watchtower.
type TLSConfig struct {
// CACert is the path to a PEM-encoded CA cert file to use to verify the
// Watchtower server SSL certificate.
CACert string
// CAPath is the path to a directory of PEM-encoded CA cert files to verify
// the Watchtower server SSL certificate.
CAPath string
// ClientCert is the path to the certificate for Watchtower communication
ClientCert string
// ClientKey is the path to the private key for Watchtower communication
ClientKey string
// ServerName, if set, is used to set the SNI host when connecting via
// TLS.
ServerName string
// Insecure enables or disables SSL verification
Insecure bool
}
// DefaultConfig returns a default configuration for the client. It is
// safe to modify the return value of this function.
//
// The default Address is https://127.0.0.1:9200, but this can be overridden by
// setting the `WATCHTOWER_ADDR` environment variable.
//
// If an error is encountered, this will return nil.
func DefaultConfig() *Config {
config := &Config{
Address: "https://127.0.0.1:9200",
HttpClient: cleanhttp.DefaultPooledClient(),
Timeout: time.Second * 60,
}
// We read the environment now; after DefaultClient returns we can override
// values from command line flags, which should take precedence.
if err := config.ReadEnvironment(); err != nil {
config.Error = err
return config
}
transport := config.HttpClient.Transport.(*http.Transport)
transport.TLSHandshakeTimeout = 10 * time.Second
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
config.Backoff = retryablehttp.LinearJitterBackoff
config.MaxRetries = 2
config.Headers = make(http.Header)
return config
}
// ConfigureTLS takes a set of TLS configurations and applies those to the the
// HTTP client.
func (c *Config) ConfigureTLS() error {
if c.HttpClient == nil {
c.HttpClient = DefaultConfig().HttpClient
}
clientTLSConfig := c.HttpClient.Transport.(*http.Transport).TLSClientConfig
var clientCert tls.Certificate
foundClientCert := false
switch {
case c.TLSConfig.ClientCert != "" && c.TLSConfig.ClientKey != "":
var err error
clientCert, err = tls.LoadX509KeyPair(c.TLSConfig.ClientCert, c.TLSConfig.ClientKey)
if err != nil {
return err
}
foundClientCert = true
case c.TLSConfig.ClientCert != "" || c.TLSConfig.ClientKey != "":
return fmt.Errorf("both client cert and client key must be provided")
}
if c.TLSConfig.CACert != "" || c.TLSConfig.CAPath != "" {
rootConfig := &rootcerts.Config{
CAFile: c.TLSConfig.CACert,
CAPath: c.TLSConfig.CAPath,
}
if err := rootcerts.ConfigureTLS(clientTLSConfig, rootConfig); err != nil {
return err
}
}
if c.TLSConfig.Insecure {
clientTLSConfig.InsecureSkipVerify = true
}
if foundClientCert {
// We use this function to ignore the server's preferential list of
// CAs, otherwise any CA used for the cert auth backend must be in the
// server's CA pool
clientTLSConfig.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return &clientCert, nil
}
}
if c.TLSConfig.ServerName != "" {
clientTLSConfig.ServerName = c.TLSConfig.ServerName
}
return nil
}
// setAddress parses a given string and looks for org and project info, setting
// the actual address to the base. Note that if a very malformed URL is passed
// in, this may not return what one expects. For now this is on purpose to
// avoid requiring error handling.
//
// This also removes any trailing "/v1"; we'll use that in our commands so we
// don't require it from users.
func (c *Config) setAddress(addr string) error {
u, err := url.Parse(addr)
if err != nil {
return fmt.Errorf("error parsing address: %w", err)
}
c.Address = fmt.Sprintf("%s://%s", u.Scheme, u.Host)
path := strings.TrimPrefix(u.Path, "/v1")
path = strings.TrimPrefix(path, "/")
if path == "" {
return nil
}
split := strings.Split(path, "/")
switch len(split) {
case 0:
case 2:
if split[0] != "org" {
return fmt.Errorf("expected org segment in address, found %q", split[0])
}
c.Org = split[1]
case 4:
if split[0] != "org" {
return fmt.Errorf("expected org segment in address, found %q", split[0])
}
c.Org = split[1]
if split[2] != "project" {
return fmt.Errorf("expected project segment in address, found %q", split[2])
}
c.Project = split[3]
default:
return fmt.Errorf("unexpected number of segments in address")
}
return nil
}
// ReadEnvironment reads configuration information from the environment. If
// there is an error, no configuration value is updated.
func (c *Config) ReadEnvironment() error {
var envCACert string
var envCAPath string
var envClientCert string
var envClientKey string
var envInsecure bool
var envServerName string
// Parse the environment variables
if v := os.Getenv(EnvWatchtowerAddress); v != "" {
c.Address = v
}
if v := os.Getenv(EnvWatchtowerToken); v != "" {
c.Token = v
}
if v := os.Getenv(EnvWatchtowerOrg); v != "" {
c.Org = v
}
if v := os.Getenv(EnvWatchtowerProject); v != "" {
c.Project = v
}
if v := os.Getenv(EnvWatchtowerMaxRetries); v != "" {
maxRetries, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return err
}
c.MaxRetries = int(maxRetries)
}
if v := os.Getenv(EnvWatchtowerSRVLookup); v != "" {
var err error
lookup, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("could not parse %s", EnvWatchtowerSRVLookup)
}
c.SRVLookup = lookup
}
if t := os.Getenv(EnvWatchtowerClientTimeout); t != "" {
clientTimeout, err := parseutil.ParseDurationSecond(t)
if err != nil {
return fmt.Errorf("could not parse %q", EnvWatchtowerClientTimeout)
}
c.Timeout = clientTimeout
}
if v := os.Getenv(EnvWatchtowerRateLimit); v != "" {
rateLimit, burstLimit, err := parseRateLimit(v)
if err != nil {
return err
}
c.Limiter = rate.NewLimiter(rate.Limit(rateLimit), burstLimit)
}
// TLS Config
{
var foundTLSConfig bool
if v := os.Getenv(EnvWatchtowerCACert); v != "" {
foundTLSConfig = true
envCACert = v
}
if v := os.Getenv(EnvWatchtowerCAPath); v != "" {
foundTLSConfig = true
envCAPath = v
}
if v := os.Getenv(EnvWatchtowerClientCert); v != "" {
foundTLSConfig = true
envClientCert = v
}
if v := os.Getenv(EnvWatchtowerClientKey); v != "" {
foundTLSConfig = true
envClientKey = v
}
if v := os.Getenv(EnvWatchtowerTLSInsecure); v != "" {
foundTLSConfig = true
var err error
envInsecure, err = strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("could not parse WATCHTOWER_TLS_INSECURE")
}
}
if v := os.Getenv(EnvWatchtowerTLSServerName); v != "" {
foundTLSConfig = true
envServerName = v
}
// Set the values on the config
// Configure the HTTP clients TLS configuration.
if foundTLSConfig {
c.TLSConfig = &TLSConfig{
CACert: envCACert,
CAPath: envCAPath,
ClientCert: envClientCert,
ClientKey: envClientKey,
ServerName: envServerName,
Insecure: envInsecure,
}
return c.ConfigureTLS()
}
}
return nil
}
func parseRateLimit(val string) (rate float64, burst int, err error) {
_, err = fmt.Sscanf(val, "%f:%d", &rate, &burst)
if err != nil {
rate, err = strconv.ParseFloat(val, 64)
if err != nil {
err = fmt.Errorf("%v was provided but incorrectly formatted", EnvWatchtowerRateLimit)
}
burst = int(rate)
}
return rate, burst, err
}
// Client is the client to the Watchtower API. Create a client with NewClient.
type Client struct {
modifyLock sync.RWMutex
config *Config
}
// NewClient returns a new client for the given configuration.
//
// If the configuration is nil, Watchtower will use configuration from
// DefaultConfig(), which is the recommended starting configuration.
//
// If the environment variable `WATCHTOWER_TOKEN` is present, the token will be
// automatically added to the client. Otherwise, you must manually call
// `SetToken()`.
func NewClient(c *Config) (*Client, error) {
def := DefaultConfig()
if def == nil {
return nil, fmt.Errorf("could not create/read default configuration")
}
if def.Error != nil {
return nil, errwrap.Wrapf("error encountered setting up default configuration: {{err}}", def.Error)
}
if c == nil {
c = def
}
if c.HttpClient == nil {
c.HttpClient = def.HttpClient
}
if c.HttpClient.Transport == nil {
c.HttpClient.Transport = def.HttpClient.Transport
}
if c.HttpClient.CheckRedirect == nil {
// Ensure redirects are not automatically followed
c.HttpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
// Returning this value causes the Go net library to not close the
// response body and to nil out the error. Otherwise retry clients may
// try three times on every redirect because it sees an error from this
// function (to prevent redirects) passing through to it.
return http.ErrUseLastResponse
}
}
if err := c.setAddress(c.Address); err != nil {
return nil, err
}
return &Client{
config: c,
}, nil
}
// Sets the address of Watchtower in the client. The format of address should
// be "<Scheme>://<Host>:<Port>". Setting this on a client will override the
// value of the WATCHTOWER_ADDR environment variable.
func (c *Client) SetAddress(addr string) error {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
return c.config.setAddress(addr)
}
// SetLimiter will set the rate limiter for this client. This method is
// thread-safe. rateLimit and burst are specified according to
// https://godoc.org/golang.org/x/time/rate#NewLimiter
func (c *Client) SetLimiter(rateLimit float64, burst int) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.Limiter = rate.NewLimiter(rate.Limit(rateLimit), burst)
}
// SetMaxRetries sets the number of retries that will be used in the case of
// certain errors
func (c *Client) SetMaxRetries(retries int) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.MaxRetries = retries
}
// SetCheckRetry sets the CheckRetry function to be used for future requests.
func (c *Client) SetCheckRetry(checkRetry retryablehttp.CheckRetry) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.CheckRetry = checkRetry
}
// SetClientTimeout sets the client request timeout
func (c *Client) SetClientTimeout(timeout time.Duration) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.Timeout = timeout
}
func (c *Client) SetOutputCurlString(curl bool) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.OutputCurlString = curl
}
// SetToken sets the token directly. This won't perform any auth
// verification, it simply sets the token properly for future requests.
func (c *Client) SetToken(token string) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.Token = token
}
// SetHeaders clears all previous headers and uses only the given
// ones going forward.
func (c *Client) SetHeaders(headers http.Header) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.Headers = headers
}
// SetBackoff sets the backoff function to be used for future requests.
func (c *Client) SetBackoff(backoff retryablehttp.Backoff) {
c.modifyLock.Lock()
defer c.modifyLock.Unlock()
c.config.Backoff = backoff
}
// Clone creates a new client with the same configuration. Note that the same
// underlying http.Client is used; modifying the client from more than one
// goroutine at once may not be safe, so modify the client as needed and then
// clone.
func (c *Client) Clone() (*Client, error) {
c.modifyLock.RLock()
defer c.modifyLock.RUnlock()
config := c.config
newConfig := &Config{
Address: config.Address,
Token: config.Token,
HttpClient: config.HttpClient,
Headers: make(http.Header),
MaxRetries: config.MaxRetries,
Timeout: config.Timeout,
Backoff: config.Backoff,
CheckRetry: config.CheckRetry,
Limiter: config.Limiter,
SRVLookup: config.SRVLookup,
}
if config.TLSConfig != nil {
newConfig.TLSConfig = new(TLSConfig)
*newConfig.TLSConfig = *config.TLSConfig
}
for k, v := range config.Headers {
vSlice := make([]string, 0, len(v))
for _, i := range v {
vSlice = append(vSlice, i)
}
newConfig.Headers[k] = vSlice
}
return NewClient(newConfig)
}
func copyHeaders(in http.Header) http.Header {
ret := make(http.Header)
for k, v := range in {
for _, val := range v {
ret[k] = append(ret[k], val)
}
}
return ret
}
// GetReaderFuncForJSON returns a func compatible with retryablehttp.ReaderFunc
// after marshaling JSON
func getBufferForJSON(val interface{}) (*bytes.Buffer, error) {
b, err := json.Marshal(val)
if err != nil {
return nil, err
}
return bytes.NewBuffer(b), nil
}
// NewRequest creates a new raw request object to query the Watchtower controller
// configured for this client. This is an advanced method and generally
// doesn't need to be called externally.
func (c *Client) NewRequest(ctx context.Context, method, requestPath string, body interface{}) (*http.Request, error) {
c.modifyLock.RLock()
addr := c.config.Address
org := c.config.Org
project := c.config.Project
srvLookup := c.config.SRVLookup
token := c.config.Token
httpClient := c.config.HttpClient
headers := copyHeaders(c.config.Headers)
c.modifyLock.RUnlock()
u, err := url.Parse(addr)
if err != nil {
return nil, err
}
if strings.HasPrefix(addr, "unix://") {
socket := strings.TrimPrefix(addr, "unix://")
transport := httpClient.Transport.(*http.Transport)
transport.DialContext = func(context.Context, string, string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", socket)
}
// Since the address points to a unix domain socket, the scheme in the
// *URL would be set to `unix`. The *URL in the client is expected to
// be pointing to the protocol used in the application layer and not to
// the transport layer. Hence, setting the fields accordingly.
u.Scheme = "http"
u.Host = socket
u.Path = ""
}
var host = u.Host
// if SRV records exist (see
// https://tools.ietf.org/html/draft-andrews-http-srv-02), lookup the SRV
// record and take the highest match; this is not designed for
// high-availability, just discovery Internet Draft specifies that the SRV
// record is ignored if a port is given
if u.Port() == "" && srvLookup {
_, addrs, err := net.LookupSRV("http", "tcp", u.Hostname())
if err != nil {
return nil, fmt.Errorf("error performing SRV lookup of http:tcp:%s : %w", u.Hostname(), err)
}
if len(addrs) > 0 {
host = fmt.Sprintf("%s:%d", addrs[0].Target, addrs[0].Port)
}
}
var ok bool
if orgRaw := ctx.Value("org"); orgRaw != nil {
if org, ok = orgRaw.(string); !ok {
return nil, fmt.Errorf("could not convert %v into string org value", orgRaw)
}
}
if projectRaw := ctx.Value("project"); projectRaw != nil {
if project, ok = projectRaw.(string); !ok {
return nil, fmt.Errorf("could not convert %v into string project value", projectRaw)
}
}
req := &http.Request{
Method: method,
URL: &url.URL{
User: u.User,
Scheme: u.Scheme,
Host: host,
Path: path.Join(u.Path, "v1", "org", org, "project", project, requestPath),
},
Host: u.Host,
}
req.Header = headers
req.Header.Add("authorization", "bearer: "+token)
if ctx != nil {
req = req.WithContext(ctx)
}
return req, nil
}
// Do takes a properly configured request and applies client configuration to
// it, returning the response.
func (c *Client) Do(r *http.Request) (*http.Response, error) {
c.modifyLock.RLock()
limiter := c.config.Limiter
maxRetries := c.config.MaxRetries
checkRetry := c.config.CheckRetry
backoff := c.config.Backoff
httpClient := c.config.HttpClient
timeout := c.config.Timeout
token := c.config.Token
outputCurlString := c.config.OutputCurlString
c.modifyLock.RUnlock()
ctx := r.Context()
if limiter != nil {
limiter.Wait(ctx)
}
// Sanity check the token before potentially erroring from the API
idx := strings.IndexFunc(token, func(c rune) bool {
return !unicode.IsPrint(c)
})
if idx != -1 {
return nil, fmt.Errorf("configured Watchtower token contains non-printable characters and cannot be used")
}
req, err := retryablehttp.FromRequest(r)
if err != nil {
return nil, fmt.Errorf("error converting request to retryable request: %w", err)
}
if req == nil {
return nil, fmt.Errorf("nil request created")
}
if outputCurlString {
LastOutputStringError = &OutputStringError{Request: req}
return nil, LastOutputStringError
}
if timeout != 0 {
// NOTE: this leaks a timer. But when we defer a cancel call here for
// the returned function we see errors in tests with contxt canceled.
// Although the request is done by the time we exit this function it is
// still causing something else to go wrong. Maybe it ends up being
// tied to the response somehow and reading the response body ends up
// checking it, or something. I don't know, but until we can chase this
// down, keep it not-canceled even though vet complains.
ctx, _ = context.WithTimeout(ctx, timeout)
}
req.Request = req.Request.WithContext(ctx)
if backoff == nil {
backoff = retryablehttp.LinearJitterBackoff
}
if checkRetry == nil {
checkRetry = retryablehttp.DefaultRetryPolicy
}
client := &retryablehttp.Client{
HTTPClient: httpClient,
RetryWaitMin: 1000 * time.Millisecond,
RetryWaitMax: 1500 * time.Millisecond,
RetryMax: maxRetries,
Backoff: backoff,
CheckRetry: checkRetry,
ErrorHandler: retryablehttp.PassthroughErrorHandler,
}
result, err := client.Do(req)
if err != nil {
if strings.Contains(err.Error(), "tls: oversized") {
err = errwrap.Wrapf(
"{{err}}\n\n"+
"This error usually means that the controller is running with TLS disabled\n"+
"but the client is configured to use TLS. Please either enable TLS\n"+
"on the server or run the client with -address set to an address\n"+
"that uses the http protocol:\n\n"+
" watchtower <command> -address http://<address>\n\n"+
"You can also set the WATCHTOWER_ADDR environment variable:\n\n\n"+
" WATCHTOWER_ADDR=http://<address> watchtower <command>\n\n"+
"where <address> is replaced by the actual address to the controller.",
err)
}
return result, err
}
return result, nil
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigSetAddress(t *testing.T) {
type test struct {
name string
input string
address string
err string
org string
project string
}
tests := []test{
{
"bare",
"http://127.0.0.1:9200",
"http://127.0.0.1:9200",
"",
"",
"",
},
{
"bare with version",
"http://127.0.0.1:9200/v1",
"http://127.0.0.1:9200",
"",
"",
"",
},
{
"bare with version and trailing slash",
"http://127.0.0.1:9200/v1/",
"http://127.0.0.1:9200",
"",
"",
"",
},
{
"invalid org",
"http://127.0.0.1:9200/v1/org",
"http://127.0.0.1:9200",
"unexpected number of segments in address",
"",
"",
},
{
"valid org",
"http://127.0.0.1:9200/v1/org/orgid",
"http://127.0.0.1:9200",
"",
"orgid",
"",
},
{
"invalid project",
"http://127.0.0.1:9200/v1/org/orgid/project",
"http://127.0.0.1:9200",
"unexpected number of segments in address",
"",
"",
},
{
"valid project",
"http://127.0.0.1:9200/v1/org/orgid/project/projid",
"http://127.0.0.1:9200",
"",
"orgid",
"projid",
},
}
for _, v := range tests {
t.Run(v.name, func(t *testing.T) {
var c Config
err := c.setAddress(v.input)
if err != nil {
assert.Equal(t, v.err, err.Error())
}
assert.Equal(t, v.address, c.Address)
assert.Equal(t, v.org, c.Org)
assert.Equal(t, v.project, c.Project)
})
}
}
+174
View File
@@ -0,0 +1,174 @@
package parseutil
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/hashicorp/errwrap"
sockaddr "github.com/hashicorp/go-sockaddr"
"github.com/hashicorp/watchtower/api/internal/strutil"
"github.com/mitchellh/mapstructure"
)
func ParseDurationSecond(in interface{}) (time.Duration, error) {
var dur time.Duration
jsonIn, ok := in.(json.Number)
if ok {
in = jsonIn.String()
}
switch inp := in.(type) {
case nil:
// return default of zero
case string:
if inp == "" {
return dur, nil
}
var err error
// Look for a suffix otherwise its a plain second value
if strings.HasSuffix(inp, "s") || strings.HasSuffix(inp, "m") || strings.HasSuffix(inp, "h") || strings.HasSuffix(inp, "ms") {
dur, err = time.ParseDuration(inp)
if err != nil {
return dur, err
}
} else {
// Plain integer
secs, err := strconv.ParseInt(inp, 10, 64)
if err != nil {
return dur, err
}
dur = time.Duration(secs) * time.Second
}
case int:
dur = time.Duration(inp) * time.Second
case int32:
dur = time.Duration(inp) * time.Second
case int64:
dur = time.Duration(inp) * time.Second
case uint:
dur = time.Duration(inp) * time.Second
case uint32:
dur = time.Duration(inp) * time.Second
case uint64:
dur = time.Duration(inp) * time.Second
case float32:
dur = time.Duration(inp) * time.Second
case float64:
dur = time.Duration(inp) * time.Second
case time.Duration:
dur = inp
default:
return 0, errors.New("could not parse duration from input")
}
return dur, nil
}
func ParseInt(in interface{}) (int64, error) {
var ret int64
jsonIn, ok := in.(json.Number)
if ok {
in = jsonIn.String()
}
switch in.(type) {
case string:
inp := in.(string)
if inp == "" {
return 0, nil
}
var err error
left, err := strconv.ParseInt(inp, 10, 64)
if err != nil {
return ret, err
}
ret = left
case int:
ret = int64(in.(int))
case int32:
ret = int64(in.(int32))
case int64:
ret = in.(int64)
case uint:
ret = int64(in.(uint))
case uint32:
ret = int64(in.(uint32))
case uint64:
ret = int64(in.(uint64))
default:
return 0, errors.New("could not parse value from input")
}
return ret, nil
}
func ParseBool(in interface{}) (bool, error) {
var result bool
if err := mapstructure.WeakDecode(in, &result); err != nil {
return false, err
}
return result, nil
}
func ParseCommaStringSlice(in interface{}) ([]string, error) {
rawString, ok := in.(string)
if ok && rawString == "" {
return []string{}, nil
}
var result []string
config := &mapstructure.DecoderConfig{
Result: &result,
WeaklyTypedInput: true,
DecodeHook: mapstructure.StringToSliceHookFunc(","),
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
return nil, err
}
if err := decoder.Decode(in); err != nil {
return nil, err
}
return strutil.TrimStrings(result), nil
}
func ParseAddrs(addrs interface{}) ([]*sockaddr.SockAddrMarshaler, error) {
out := make([]*sockaddr.SockAddrMarshaler, 0)
stringAddrs := make([]string, 0)
switch addrs.(type) {
case string:
stringAddrs = strutil.ParseArbitraryStringSlice(addrs.(string), ",")
if len(stringAddrs) == 0 {
return nil, fmt.Errorf("unable to parse addresses from %v", addrs)
}
case []string:
stringAddrs = addrs.([]string)
case []interface{}:
for _, v := range addrs.([]interface{}) {
stringAddr, ok := v.(string)
if !ok {
return nil, fmt.Errorf("error parsing %v as string", v)
}
stringAddrs = append(stringAddrs, stringAddr)
}
default:
return nil, fmt.Errorf("unknown address input type %T", addrs)
}
for _, addr := range stringAddrs {
sa, err := sockaddr.NewSockAddr(addr)
if err != nil {
return nil, errwrap.Wrapf(fmt.Sprintf("error parsing address %q: {{err}}", addr), err)
}
out = append(out, &sockaddr.SockAddrMarshaler{
SockAddr: sa,
})
}
return out, nil
}
+447
View File
@@ -0,0 +1,447 @@
package strutil
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/hashicorp/errwrap"
glob "github.com/ryanuber/go-glob"
)
// StrListContainsGlob looks for a string in a list of strings and allows
// globs.
func StrListContainsGlob(haystack []string, needle string) bool {
for _, item := range haystack {
if glob.Glob(item, needle) {
return true
}
}
return false
}
// StrListContains looks for a string in a list of strings.
func StrListContains(haystack []string, needle string) bool {
for _, item := range haystack {
if item == needle {
return true
}
}
return false
}
// StrListSubset checks if a given list is a subset
// of another set
func StrListSubset(super, sub []string) bool {
for _, item := range sub {
if !StrListContains(super, item) {
return false
}
}
return true
}
// ParseDedupAndSortStrings parses a comma separated list of strings
// into a slice of strings. The return slice will be sorted and will
// not contain duplicate or empty items.
func ParseDedupAndSortStrings(input string, sep string) []string {
input = strings.TrimSpace(input)
parsed := []string{}
if input == "" {
// Don't return nil
return parsed
}
return RemoveDuplicates(strings.Split(input, sep), false)
}
// ParseDedupLowercaseAndSortStrings parses a comma separated list of
// strings into a slice of strings. The return slice will be sorted and
// will not contain duplicate or empty items. The values will be converted
// to lower case.
func ParseDedupLowercaseAndSortStrings(input string, sep string) []string {
input = strings.TrimSpace(input)
parsed := []string{}
if input == "" {
// Don't return nil
return parsed
}
return RemoveDuplicates(strings.Split(input, sep), true)
}
// ParseKeyValues parses a comma separated list of `<key>=<value>` tuples
// into a map[string]string.
func ParseKeyValues(input string, out map[string]string, sep string) error {
if out == nil {
return fmt.Errorf("'out is nil")
}
keyValues := ParseDedupLowercaseAndSortStrings(input, sep)
if len(keyValues) == 0 {
return nil
}
for _, keyValue := range keyValues {
shards := strings.Split(keyValue, "=")
if len(shards) != 2 {
return fmt.Errorf("invalid <key,value> format")
}
key := strings.TrimSpace(shards[0])
value := strings.TrimSpace(shards[1])
if key == "" || value == "" {
return fmt.Errorf("invalid <key,value> pair: key: %q value: %q", key, value)
}
out[key] = value
}
return nil
}
// ParseArbitraryKeyValues parses arbitrary <key,value> tuples. The input
// can be one of the following:
// * JSON string
// * Base64 encoded JSON string
// * Comma separated list of `<key>=<value>` pairs
// * Base64 encoded string containing comma separated list of
// `<key>=<value>` pairs
//
// Input will be parsed into the output parameter, which should
// be a non-nil map[string]string.
func ParseArbitraryKeyValues(input string, out map[string]string, sep string) error {
input = strings.TrimSpace(input)
if input == "" {
return nil
}
if out == nil {
return fmt.Errorf("'out' is nil")
}
// Try to base64 decode the input. If successful, consider the decoded
// value as input.
inputBytes, err := base64.StdEncoding.DecodeString(input)
if err == nil {
input = string(inputBytes)
}
// Try to JSON unmarshal the input. If successful, consider that the
// metadata was supplied as JSON input.
err = json.Unmarshal([]byte(input), &out)
if err != nil {
// If JSON unmarshalling fails, consider that the input was
// supplied as a comma separated string of 'key=value' pairs.
if err = ParseKeyValues(input, out, sep); err != nil {
return errwrap.Wrapf("failed to parse the input: {{err}}", err)
}
}
// Validate the parsed input
for key, value := range out {
if key != "" && value == "" {
return fmt.Errorf("invalid value for key %q", key)
}
}
return nil
}
// ParseStringSlice parses a `sep`-separated list of strings into a
// []string with surrounding whitespace removed.
//
// The output will always be a valid slice but may be of length zero.
func ParseStringSlice(input string, sep string) []string {
input = strings.TrimSpace(input)
if input == "" {
return []string{}
}
splitStr := strings.Split(input, sep)
ret := make([]string, len(splitStr))
for i, val := range splitStr {
ret[i] = strings.TrimSpace(val)
}
return ret
}
// ParseArbitraryStringSlice parses arbitrary string slice. The input
// can be one of the following:
// * JSON string
// * Base64 encoded JSON string
// * `sep` separated list of values
// * Base64-encoded string containing a `sep` separated list of values
//
// Note that the separator is ignored if the input is found to already be in a
// structured format (e.g., JSON)
//
// The output will always be a valid slice but may be of length zero.
func ParseArbitraryStringSlice(input string, sep string) []string {
input = strings.TrimSpace(input)
if input == "" {
return []string{}
}
// Try to base64 decode the input. If successful, consider the decoded
// value as input.
inputBytes, err := base64.StdEncoding.DecodeString(input)
if err == nil {
input = string(inputBytes)
}
ret := []string{}
// Try to JSON unmarshal the input. If successful, consider that the
// metadata was supplied as JSON input.
err = json.Unmarshal([]byte(input), &ret)
if err != nil {
// If JSON unmarshalling fails, consider that the input was
// supplied as a separated string of values.
return ParseStringSlice(input, sep)
}
if ret == nil {
return []string{}
}
return ret
}
// TrimStrings takes a slice of strings and returns a slice of strings
// with trimmed spaces
func TrimStrings(items []string) []string {
ret := make([]string, len(items))
for i, item := range items {
ret[i] = strings.TrimSpace(item)
}
return ret
}
// RemoveDuplicates removes duplicate and empty elements from a slice of
// strings. This also may convert the items in the slice to lower case and
// returns a sorted slice.
func RemoveDuplicates(items []string, lowercase bool) []string {
itemsMap := map[string]bool{}
for _, item := range items {
item = strings.TrimSpace(item)
if lowercase {
item = strings.ToLower(item)
}
if item == "" {
continue
}
itemsMap[item] = true
}
items = make([]string, 0, len(itemsMap))
for item := range itemsMap {
items = append(items, item)
}
sort.Strings(items)
return items
}
// RemoveDuplicatesStable removes duplicate and empty elements from a slice of
// strings, preserving order (and case) of the original slice.
// In all cases, strings are compared after trimming whitespace
// If caseInsensitive, strings will be compared after ToLower()
func RemoveDuplicatesStable(items []string, caseInsensitive bool) []string {
itemsMap := make(map[string]bool, len(items))
deduplicated := make([]string, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item)
if caseInsensitive {
key = strings.ToLower(key)
}
if key == "" || itemsMap[key] {
continue
}
itemsMap[key] = true
deduplicated = append(deduplicated, item)
}
return deduplicated
}
// RemoveEmpty removes empty elements from a slice of
// strings
func RemoveEmpty(items []string) []string {
if len(items) == 0 {
return items
}
itemsSlice := make([]string, 0, len(items))
for _, item := range items {
if item == "" {
continue
}
itemsSlice = append(itemsSlice, item)
}
return itemsSlice
}
// EquivalentSlices checks whether the given string sets are equivalent, as in,
// they contain the same values.
func EquivalentSlices(a, b []string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
// First we'll build maps to ensure unique values
mapA := map[string]bool{}
mapB := map[string]bool{}
for _, keyA := range a {
mapA[keyA] = true
}
for _, keyB := range b {
mapB[keyB] = true
}
// Now we'll build our checking slices
var sortedA, sortedB []string
for keyA := range mapA {
sortedA = append(sortedA, keyA)
}
for keyB := range mapB {
sortedB = append(sortedB, keyB)
}
sort.Strings(sortedA)
sort.Strings(sortedB)
// Finally, compare
if len(sortedA) != len(sortedB) {
return false
}
for i := range sortedA {
if sortedA[i] != sortedB[i] {
return false
}
}
return true
}
// EqualStringMaps tests whether two map[string]string objects are equal.
// Equal means both maps have the same sets of keys and values. This function
// is 6-10x faster than a call to reflect.DeepEqual().
func EqualStringMaps(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k := range a {
v, ok := b[k]
if !ok || a[k] != v {
return false
}
}
return true
}
// StrListDelete removes the first occurrence of the given item from the slice
// of strings if the item exists.
func StrListDelete(s []string, d string) []string {
if s == nil {
return s
}
for index, element := range s {
if element == d {
return append(s[:index], s[index+1:]...)
}
}
return s
}
// GlobbedStringsMatch compares item to val with support for a leading and/or
// trailing wildcard '*' in item.
func GlobbedStringsMatch(item, val string) bool {
if len(item) < 2 {
return val == item
}
hasPrefix := strings.HasPrefix(item, "*")
hasSuffix := strings.HasSuffix(item, "*")
if hasPrefix && hasSuffix {
return strings.Contains(val, item[1:len(item)-1])
} else if hasPrefix {
return strings.HasSuffix(val, item[1:])
} else if hasSuffix {
return strings.HasPrefix(val, item[:len(item)-1])
}
return val == item
}
// AppendIfMissing adds a string to a slice if the given string is not present
func AppendIfMissing(slice []string, i string) []string {
if StrListContains(slice, i) {
return slice
}
return append(slice, i)
}
// MergeSlices adds an arbitrary number of slices together, uniquely
func MergeSlices(args ...[]string) []string {
all := map[string]struct{}{}
for _, slice := range args {
for _, v := range slice {
all[v] = struct{}{}
}
}
result := make([]string, 0, len(all))
for k := range all {
result = append(result, k)
}
sort.Strings(result)
return result
}
// Difference returns the set difference (A - B) of the two given slices. The
// result will also remove any duplicated values in set A regardless of whether
// that matches any values in set B.
func Difference(a, b []string, lowercase bool) []string {
if len(a) == 0 {
return a
}
if len(b) == 0 {
if !lowercase {
return a
}
newA := make([]string, len(a))
for i, v := range a {
newA[i] = strings.ToLower(v)
}
return newA
}
a = RemoveDuplicates(a, lowercase)
b = RemoveDuplicates(b, lowercase)
itemsMap := map[string]bool{}
for _, aVal := range a {
itemsMap[aVal] = true
}
// Perform difference calculation
for _, bVal := range b {
if _, ok := itemsMap[bVal]; ok {
itemsMap[bVal] = false
}
}
items := []string{}
for item, exists := range itemsMap {
if exists {
items = append(items, item)
}
}
sort.Strings(items)
return items
}
+601
View File
@@ -0,0 +1,601 @@
package strutil
import (
"encoding/base64"
"encoding/json"
"reflect"
"testing"
)
func TestStrUtil_StrListDelete(t *testing.T) {
output := StrListDelete([]string{"item1", "item2", "item3"}, "item1")
if StrListContains(output, "item1") {
t.Fatal("bad: 'item1' should not have been present")
}
output = StrListDelete([]string{"item1", "item2", "item3"}, "item2")
if StrListContains(output, "item2") {
t.Fatal("bad: 'item2' should not have been present")
}
output = StrListDelete([]string{"item1", "item2", "item3"}, "item3")
if StrListContains(output, "item3") {
t.Fatal("bad: 'item3' should not have been present")
}
output = StrListDelete([]string{"item1", "item1", "item3"}, "item1")
if !StrListContains(output, "item1") {
t.Fatal("bad: 'item1' should have been present")
}
output = StrListDelete(output, "item1")
if StrListContains(output, "item1") {
t.Fatal("bad: 'item1' should not have been present")
}
output = StrListDelete(output, "random")
if len(output) != 1 {
t.Fatalf("bad: expected: 1, actual: %d", len(output))
}
output = StrListDelete(output, "item3")
if StrListContains(output, "item3") {
t.Fatal("bad: 'item3' should not have been present")
}
}
func TestStrutil_EquivalentSlices(t *testing.T) {
slice1 := []string{"test2", "test1", "test3"}
slice2 := []string{"test3", "test2", "test1"}
if !EquivalentSlices(slice1, slice2) {
t.Fatalf("bad: expected a match")
}
slice2 = append(slice2, "test4")
if EquivalentSlices(slice1, slice2) {
t.Fatalf("bad: expected a mismatch")
}
}
func TestStrutil_ListContainsGlob(t *testing.T) {
haystack := []string{
"dev",
"ops*",
"root/*",
"*-dev",
"_*_",
}
if StrListContainsGlob(haystack, "tubez") {
t.Fatalf("Value shouldn't exist")
}
if !StrListContainsGlob(haystack, "root/test") {
t.Fatalf("Value should exist")
}
if !StrListContainsGlob(haystack, "ops_test") {
t.Fatalf("Value should exist")
}
if !StrListContainsGlob(haystack, "ops") {
t.Fatalf("Value should exist")
}
if !StrListContainsGlob(haystack, "dev") {
t.Fatalf("Value should exist")
}
if !StrListContainsGlob(haystack, "test-dev") {
t.Fatalf("Value should exist")
}
if !StrListContainsGlob(haystack, "_test_") {
t.Fatalf("Value should exist")
}
}
func TestStrutil_ListContains(t *testing.T) {
haystack := []string{
"dev",
"ops",
"prod",
"root",
}
if StrListContains(haystack, "tubez") {
t.Fatalf("Bad")
}
if !StrListContains(haystack, "root") {
t.Fatalf("Bad")
}
}
func TestStrutil_ListSubset(t *testing.T) {
parent := []string{
"dev",
"ops",
"prod",
"root",
}
child := []string{
"prod",
"ops",
}
if !StrListSubset(parent, child) {
t.Fatalf("Bad")
}
if !StrListSubset(parent, parent) {
t.Fatalf("Bad")
}
if !StrListSubset(child, child) {
t.Fatalf("Bad")
}
if !StrListSubset(child, nil) {
t.Fatalf("Bad")
}
if StrListSubset(child, parent) {
t.Fatalf("Bad")
}
if StrListSubset(nil, child) {
t.Fatalf("Bad")
}
}
func TestStrutil_ParseKeyValues(t *testing.T) {
actual := make(map[string]string)
expected := map[string]string{
"key1": "value1",
"key2": "value2",
}
var input string
var err error
input = "key1=value1,key2=value2"
err = ParseKeyValues(input, actual, ",")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual)
}
for k, _ := range actual {
delete(actual, k)
}
input = "key1 = value1, key2 = value2"
err = ParseKeyValues(input, actual, ",")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual)
}
for k, _ := range actual {
delete(actual, k)
}
input = "key1 = value1, key2 = "
err = ParseKeyValues(input, actual, ",")
if err == nil {
t.Fatalf("expected an error")
}
for k, _ := range actual {
delete(actual, k)
}
input = "key1 = value1, = value2 "
err = ParseKeyValues(input, actual, ",")
if err == nil {
t.Fatalf("expected an error")
}
for k, _ := range actual {
delete(actual, k)
}
input = "key1"
err = ParseKeyValues(input, actual, ",")
if err == nil {
t.Fatalf("expected an error")
}
}
func TestStrutil_ParseArbitraryKeyValues(t *testing.T) {
actual := make(map[string]string)
expected := map[string]string{
"key1": "value1",
"key2": "value2",
}
var input string
var err error
// Test <key>=<value> as comma separated string
input = "key1=value1,key2=value2"
err = ParseArbitraryKeyValues(input, actual, ",")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual)
}
for k, _ := range actual {
delete(actual, k)
}
// Test <key>=<value> as base64 encoded comma separated string
input = base64.StdEncoding.EncodeToString([]byte(input))
err = ParseArbitraryKeyValues(input, actual, ",")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual)
}
for k, _ := range actual {
delete(actual, k)
}
// Test JSON encoded <key>=<value> tuples
input = `{"key1":"value1", "key2":"value2"}`
err = ParseArbitraryKeyValues(input, actual, ",")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual)
}
for k, _ := range actual {
delete(actual, k)
}
// Test base64 encoded JSON string of <key>=<value> tuples
input = base64.StdEncoding.EncodeToString([]byte(input))
err = ParseArbitraryKeyValues(input, actual, ",")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("bad: expected: %#v\nactual: %#v", expected, actual)
}
for k, _ := range actual {
delete(actual, k)
}
}
func TestStrutil_ParseArbitraryStringSlice(t *testing.T) {
input := `CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';GRANT "foo-role" TO "{{name}}";ALTER ROLE "{{name}}" SET search_path = foo;GRANT CONNECT ON DATABASE "postgres" TO "{{name}}";`
jsonExpected := []string{
`DO $$
BEGIN
IF NOT EXISTS (SELECT * FROM pg_catalog.pg_roles WHERE rolname='foo-role') THEN
CREATE ROLE "foo-role";
CREATE SCHEMA IF NOT EXISTS foo AUTHORIZATION "foo-role";
ALTER ROLE "foo-role" SET search_path = foo;
GRANT TEMPORARY ON DATABASE "postgres" TO "foo-role";
GRANT ALL PRIVILEGES ON SCHEMA foo TO "foo-role";
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA foo TO "foo-role";
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA foo TO "foo-role";
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA foo TO "foo-role";
END IF;
END
$$`,
`CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'`,
`GRANT "foo-role" TO "{{name}}"`,
`ALTER ROLE "{{name}}" SET search_path = foo`,
`GRANT CONNECT ON DATABASE "postgres" TO "{{name}}"`,
``,
}
nonJSONExpected := jsonExpected[1:]
var actual []string
var inputB64 string
var err error
// Test non-JSON string
actual = ParseArbitraryStringSlice(input, ";")
if !reflect.DeepEqual(nonJSONExpected, actual) {
t.Fatalf("bad: expected:\n%#v\nactual:\n%#v", nonJSONExpected, actual)
}
// Test base64-encoded non-JSON string
inputB64 = base64.StdEncoding.EncodeToString([]byte(input))
actual = ParseArbitraryStringSlice(inputB64, ";")
if !reflect.DeepEqual(nonJSONExpected, actual) {
t.Fatalf("bad: expected:\n%#v\nactual:\n%#v", nonJSONExpected, actual)
}
// Test JSON encoded
inputJSON, err := json.Marshal(jsonExpected)
if err != nil {
t.Fatal(err)
}
actual = ParseArbitraryStringSlice(string(inputJSON), ";")
if !reflect.DeepEqual(jsonExpected, actual) {
t.Fatalf("bad: expected:\n%#v\nactual:\n%#v", string(inputJSON), actual)
}
// Test base64 encoded JSON string of <key>=<value> tuples
inputB64 = base64.StdEncoding.EncodeToString(inputJSON)
actual = ParseArbitraryStringSlice(inputB64, ";")
if !reflect.DeepEqual(jsonExpected, actual) {
t.Fatalf("bad: expected:\n%#v\nactual:\n%#v", jsonExpected, actual)
}
}
func TestGlobbedStringsMatch(t *testing.T) {
type tCase struct {
item string
val string
expect bool
}
tCases := []tCase{
tCase{"", "", true},
tCase{"*", "*", true},
tCase{"**", "**", true},
tCase{"*t", "t", true},
tCase{"*t", "test", true},
tCase{"t*", "test", true},
tCase{"*test", "test", true},
tCase{"*test", "a test", true},
tCase{"test", "a test", false},
tCase{"*test", "tests", false},
tCase{"test*", "test", true},
tCase{"test*", "testsss", true},
tCase{"test**", "testsss", false},
tCase{"test**", "test*", true},
tCase{"**test", "*test", true},
tCase{"TEST", "test", false},
tCase{"test", "test", true},
}
for _, tc := range tCases {
actual := GlobbedStringsMatch(tc.item, tc.val)
if actual != tc.expect {
t.Fatalf("Bad testcase %#v, expected %t, got %t", tc, tc.expect, actual)
}
}
}
func TestTrimStrings(t *testing.T) {
input := []string{"abc", "123", "abcd ", "123 "}
expected := []string{"abc", "123", "abcd", "123"}
actual := TrimStrings(input)
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("Bad TrimStrings: expected:%#v, got:%#v", expected, actual)
}
}
func TestRemoveEmpty(t *testing.T) {
input := []string{"abc", "", "abc", ""}
expected := []string{"abc", "abc"}
actual := RemoveEmpty(input)
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("Bad TrimStrings: expected:%#v, got:%#v", expected, actual)
}
input = []string{""}
expected = []string{}
actual = RemoveEmpty(input)
if !reflect.DeepEqual(expected, actual) {
t.Fatalf("Bad TrimStrings: expected:%#v, got:%#v", expected, actual)
}
}
func TestStrutil_AppendIfMissing(t *testing.T) {
keys := []string{}
keys = AppendIfMissing(keys, "foo")
if len(keys) != 1 {
t.Fatalf("expected slice to be length of 1: %v", keys)
}
if keys[0] != "foo" {
t.Fatalf("expected slice to contain key 'foo': %v", keys)
}
keys = AppendIfMissing(keys, "bar")
if len(keys) != 2 {
t.Fatalf("expected slice to be length of 2: %v", keys)
}
if keys[0] != "foo" {
t.Fatalf("expected slice to contain key 'foo': %v", keys)
}
if keys[1] != "bar" {
t.Fatalf("expected slice to contain key 'bar': %v", keys)
}
keys = AppendIfMissing(keys, "foo")
if len(keys) != 2 {
t.Fatalf("expected slice to still be length of 2: %v", keys)
}
if keys[0] != "foo" {
t.Fatalf("expected slice to still contain key 'foo': %v", keys)
}
if keys[1] != "bar" {
t.Fatalf("expected slice to still contain key 'bar': %v", keys)
}
}
func TestStrUtil_RemoveDuplicates(t *testing.T) {
type tCase struct {
input []string
expect []string
lowercase bool
}
tCases := []tCase{
tCase{[]string{}, []string{}, false},
tCase{[]string{}, []string{}, true},
tCase{[]string{"a", "b", "a"}, []string{"a", "b"}, false},
tCase{[]string{"A", "b", "a"}, []string{"A", "a", "b"}, false},
tCase{[]string{"A", "b", "a"}, []string{"a", "b"}, true},
}
for _, tc := range tCases {
actual := RemoveDuplicates(tc.input, tc.lowercase)
if !reflect.DeepEqual(actual, tc.expect) {
t.Fatalf("Bad testcase %#v, expected %v, got %v", tc, tc.expect, actual)
}
}
}
func TestStrUtil_RemoveDuplicatesStable(t *testing.T) {
type tCase struct {
input []string
expect []string
caseInsensitive bool
}
tCases := []tCase{
tCase{[]string{}, []string{}, false},
tCase{[]string{}, []string{}, true},
tCase{[]string{"a", "b", "a"}, []string{"a", "b"}, false},
tCase{[]string{"A", "b", "a"}, []string{"A", "b", "a"}, false},
tCase{[]string{"A", "b", "a"}, []string{"A", "b"}, true},
tCase{[]string{" ", "d", "c", "d"}, []string{"d", "c"}, false},
tCase{[]string{"Z ", " z", " z ", "y"}, []string{"Z ", "y"}, true},
tCase{[]string{"Z ", " z", " z ", "y"}, []string{"Z ", " z", "y"}, false},
}
for _, tc := range tCases {
actual := RemoveDuplicatesStable(tc.input, tc.caseInsensitive)
if !reflect.DeepEqual(actual, tc.expect) {
t.Fatalf("Bad testcase %#v, expected %v, got %v", tc, tc.expect, actual)
}
}
}
func TestStrUtil_ParseStringSlice(t *testing.T) {
type tCase struct {
input string
sep string
expect []string
}
tCases := []tCase{
tCase{"", "", []string{}},
tCase{" ", ",", []string{}},
tCase{", ", ",", []string{"", ""}},
tCase{"a", ",", []string{"a"}},
tCase{" a, b, c ", ",", []string{"a", "b", "c"}},
tCase{" a; b; c ", ";", []string{"a", "b", "c"}},
tCase{" a :: b :: c ", "::", []string{"a", "b", "c"}},
}
for _, tc := range tCases {
actual := ParseStringSlice(tc.input, tc.sep)
if !reflect.DeepEqual(actual, tc.expect) {
t.Fatalf("Bad testcase %#v, expected %v, got %v", tc, tc.expect, actual)
}
}
}
func TestStrUtil_MergeSlices(t *testing.T) {
res := MergeSlices([]string{"a", "c", "d"}, []string{}, []string{"c", "f", "a"}, nil, []string{"foo"})
expect := []string{"a", "c", "d", "f", "foo"}
if !reflect.DeepEqual(res, expect) {
t.Fatalf("expected %v, got %v", expect, res)
}
}
func TestDifference(t *testing.T) {
testCases := []struct {
Name string
SetA []string
SetB []string
Lowercase bool
ExpectedResult []string
}{
{
Name: "case_sensitive",
SetA: []string{"a", "b", "c"},
SetB: []string{"b", "c"},
Lowercase: false,
ExpectedResult: []string{"a"},
},
{
Name: "case_insensitive",
SetA: []string{"a", "B", "c"},
SetB: []string{"b", "C"},
Lowercase: true,
ExpectedResult: []string{"a"},
},
{
Name: "no_match",
SetA: []string{"a", "b", "c"},
SetB: []string{"d"},
Lowercase: false,
ExpectedResult: []string{"a", "b", "c"},
},
{
Name: "empty_set_a",
SetA: []string{},
SetB: []string{"d", "e"},
Lowercase: false,
ExpectedResult: []string{},
},
{
Name: "empty_set_b",
SetA: []string{"a", "b"},
SetB: []string{},
Lowercase: false,
ExpectedResult: []string{"a", "b"},
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
actualResult := Difference(tc.SetA, tc.SetB, tc.Lowercase)
if !reflect.DeepEqual(actualResult, tc.ExpectedResult) {
t.Fatalf("expected %v, got %v", tc.ExpectedResult, actualResult)
}
})
}
}
func TestStrUtil_EqualStringMaps(t *testing.T) {
m1 := map[string]string{
"foo": "a",
}
m2 := map[string]string{
"foo": "a",
"bar": "b",
}
var m3 map[string]string
m4 := map[string]string{
"dog": "",
}
m5 := map[string]string{
"cat": "",
}
tests := []struct {
a map[string]string
b map[string]string
result bool
}{
{m1, m1, true},
{m2, m2, true},
{m1, m2, false},
{m2, m1, false},
{m2, m2, true},
{m3, m1, false},
{m3, m3, true},
{m4, m5, false},
}
for i, test := range tests {
actual := EqualStringMaps(test.a, test.b)
if actual != test.result {
t.Fatalf("case %d, expected %v, got %v", i, test.result, actual)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"fmt"
"strings"
retryablehttp "github.com/hashicorp/go-retryablehttp"
)
const (
ErrOutputStringRequest = "output a string, please"
)
var (
LastOutputStringError *OutputStringError
)
type OutputStringError struct {
*retryablehttp.Request
parsingError error
parsedCurlString string
}
func (d *OutputStringError) Error() string {
if d.parsedCurlString == "" {
d.parseRequest()
if d.parsingError != nil {
return d.parsingError.Error()
}
}
return ErrOutputStringRequest
}
func (d *OutputStringError) parseRequest() {
body, err := d.Request.BodyBytes()
if err != nil {
d.parsingError = err
return
}
// Build cURL string
d.parsedCurlString = "curl "
if d.Request.Method != "GET" {
d.parsedCurlString = fmt.Sprintf("%s-X %s ", d.parsedCurlString, d.Request.Method)
}
for k, v := range d.Request.Header {
for _, h := range v {
if strings.ToLower(k) == "authorization" {
h = `Bearer: <token>`
}
d.parsedCurlString = fmt.Sprintf("%s-H \"%s: %s\" ", d.parsedCurlString, k, h)
}
}
if len(body) > 0 {
// We need to escape single quotes since that's what we're using to
// quote the body
escapedBody := strings.Replace(string(body), "'", "'\"'\"'", -1)
d.parsedCurlString = fmt.Sprintf("%s-d '%s' ", d.parsedCurlString, escapedBody)
}
d.parsedCurlString = fmt.Sprintf("%s%s", d.parsedCurlString, d.Request.URL.String())
}
func (d *OutputStringError) CurlString() string {
if d.parsedCurlString == "" {
d.parseRequest()
}
return d.parsedCurlString
}
+7
View File
@@ -17,9 +17,13 @@ require (
github.com/grpc-ecosystem/grpc-gateway v1.14.4-rc.1
github.com/hashicorp/errwrap v1.0.0
github.com/hashicorp/go-alpnmux v0.0.0-20200323180452-dee08f00df54
github.com/hashicorp/go-cleanhttp v0.5.1
github.com/hashicorp/go-hclog v0.12.2
github.com/hashicorp/go-kms-wrapping v0.5.8
github.com/hashicorp/go-multierror v1.0.0
github.com/hashicorp/go-retryablehttp v0.6.2
github.com/hashicorp/go-rootcerts v1.0.2
github.com/hashicorp/go-sockaddr v1.0.2
github.com/hashicorp/go-uuid v1.0.2
github.com/hashicorp/hcl v1.0.0
github.com/hashicorp/vault v1.2.1-0.20200310200732-a321b1217d5a
@@ -38,10 +42,13 @@ require (
github.com/pkg/errors v0.9.1
github.com/posener/complete v1.2.3
github.com/ryanuber/columnize v2.1.0+incompatible
github.com/ryanuber/go-glob v1.0.0
github.com/spf13/viper v1.6.3 // indirect
github.com/stretchr/testify v1.5.1
go.mongodb.org/mongo-driver v1.3.2 // indirect
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4
golang.org/x/tools v0.0.0-20200421185700-66008de356c7 // indirect
google.golang.org/genproto v0.0.0-20200409111301-baae70f3302d
google.golang.org/grpc v1.27.1
+1 -11
View File
@@ -26,14 +26,4 @@ const (
const EnvWatchtowerCLINoColor = `WATCHTOWER_CLI_NO_COLOR`
const EnvWatchtowerCLIFormat = `WATCHTOWER_CLI_FORMAT`
const EnvWatchtowerAddress = "WATCHTOWER_ADDR"
const EnvWatchtowerCACert = "WATCHTOWER_CACERT"
const EnvWatchtowerCAPath = "WATCHTOWER_CAPATH"
const EnvWatchtowerClientCert = "WATCHTOWER_CLIENT_CERT"
const EnvWatchtowerClientKey = "WATCHTOWER_CLIENT_KEY"
const EnvWatchtowerClientTimeout = "WATCHTOWER_CLIENT_TIMEOUT"
const EnvWatchtowerTLSInsecure = "WATCHTOWER_TLS_INSECURE"
const EnvWatchtowerTLSServerName = "WATCHTOWER_TLS_SERVER_NAME"
const EnvWatchtowerMaxRetries = "WATCHTOWER_MAX_RETRIES"
const EnvWatchtowerToken = "WATCHTOWER_TOKEN"
const EnvRateLimit = "WATCHTOWER_RATE_LIMIT"