Merge branch 'main' into f-gh-13120-sso-umbrella

This commit is contained in:
James Rasell
2022-08-02 08:30:03 +01:00
402 changed files with 23238 additions and 2429 deletions

View File

@@ -452,6 +452,20 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) {
return nil, fmt.Errorf("deploy_query_rate_limit must be greater than 0")
}
// Set plan rejection tracker configuration.
if planRejectConf := agentConfig.Server.PlanRejectionTracker; planRejectConf != nil {
if planRejectConf.Enabled != nil {
conf.NodePlanRejectionEnabled = *planRejectConf.Enabled
}
conf.NodePlanRejectionThreshold = planRejectConf.NodeThreshold
if planRejectConf.NodeWindow == 0 {
return nil, fmt.Errorf("plan_rejection_tracker.node_window must be greater than 0")
} else {
conf.NodePlanRejectionWindow = planRejectConf.NodeWindow
}
}
// Add Enterprise license configs
conf.LicenseEnv = agentConfig.Server.LicenseEnv
conf.LicensePath = agentConfig.Server.LicensePath

View File

@@ -9,15 +9,17 @@ import (
"testing"
"time"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hashicorp/nomad/ci"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/nomad/structs/config"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hashicorp/nomad/testutil"
)
func TestAgent_RPC_Ping(t *testing.T) {
@@ -366,6 +368,82 @@ func TestAgent_ServerConfig_Limits_OK(t *testing.T) {
}
}
func TestAgent_ServerConfig_PlanRejectionTracker(t *testing.T) {
ci.Parallel(t)
cases := []struct {
name string
trackerConfig *PlanRejectionTracker
expectedConfig *PlanRejectionTracker
expectedErr string
}{
{
name: "default",
trackerConfig: nil,
expectedConfig: &PlanRejectionTracker{
NodeThreshold: 100,
NodeWindow: 5 * time.Minute,
},
expectedErr: "",
},
{
name: "valid config",
trackerConfig: &PlanRejectionTracker{
Enabled: helper.BoolToPtr(true),
NodeThreshold: 123,
NodeWindow: 17 * time.Minute,
},
expectedConfig: &PlanRejectionTracker{
Enabled: helper.BoolToPtr(true),
NodeThreshold: 123,
NodeWindow: 17 * time.Minute,
},
expectedErr: "",
},
{
name: "invalid node window",
trackerConfig: &PlanRejectionTracker{
NodeThreshold: 123,
},
expectedErr: "node_window must be greater than 0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
config := DevConfig(nil)
require.NoError(t, config.normalizeAddrs())
if tc.trackerConfig != nil {
config.Server.PlanRejectionTracker = tc.trackerConfig
}
serverConfig, err := convertServerConfig(config)
if tc.expectedErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErr)
} else {
require.NoError(t, err)
if tc.expectedConfig.Enabled != nil {
require.Equal(t,
*tc.expectedConfig.Enabled,
serverConfig.NodePlanRejectionEnabled,
)
}
require.Equal(t,
tc.expectedConfig.NodeThreshold,
serverConfig.NodePlanRejectionThreshold,
)
require.Equal(t,
tc.expectedConfig.NodeWindow,
serverConfig.NodePlanRejectionWindow,
)
}
})
}
}
func TestAgent_ServerConfig_RaftMultiplier_Ok(t *testing.T) {
ci.Parallel(t)
@@ -1345,10 +1423,17 @@ func TestAgent_ProxyRPC_Dev(t *testing.T) {
},
}
time.Sleep(100 * time.Millisecond)
testutil.WaitForResultUntil(time.Second,
func() (bool, error) {
var resp cstructs.ClientStatsResponse
err := agent.RPC("ClientStats.Stats", req, &resp)
if err != nil {
return false, err
}
return true, nil
},
func(err error) {
t.Fatalf("was unable to read ClientStats.Stats RPC: %v", err)
})
var resp cstructs.ClientStatsResponse
if err := agent.RPC("ClientStats.Stats", req, &resp); err != nil {
t.Fatalf("err: %v", err)
}
}

View File

@@ -84,6 +84,8 @@ func (s *HTTPServer) AllocSpecificRequest(resp http.ResponseWriter, req *http.Re
}
switch tokens[1] {
case "checks":
return s.allocChecks(allocID, resp, req)
case "stop":
return s.allocStop(allocID, resp, req)
case "services":
@@ -213,6 +215,8 @@ func (s *HTTPServer) ClientAllocRequest(resp http.ResponseWriter, req *http.Requ
}
allocID := tokens[0]
switch tokens[1] {
case "checks":
return s.allocChecks(allocID, resp, req)
case "stats":
return s.allocStats(allocID, resp, req)
case "exec":
@@ -436,6 +440,39 @@ func (s *HTTPServer) allocStats(allocID string, resp http.ResponseWriter, req *h
return reply.Stats, rpcErr
}
func (s *HTTPServer) allocChecks(allocID string, resp http.ResponseWriter, req *http.Request) (any, error) {
// Build the request and parse the ACL token
args := cstructs.AllocChecksRequest{
AllocID: allocID,
}
s.parse(resp, req, &args.QueryOptions.Region, &args.QueryOptions)
// Determine the handler to use
useLocalClient, useClientRPC, useServerRPC := s.rpcHandlerForAlloc(allocID)
// Make the RPC
var reply cstructs.AllocChecksResponse
var rpcErr error
switch {
case useLocalClient:
rpcErr = s.agent.Client().ClientRPC("Allocations.Checks", &args, &reply)
case useClientRPC:
rpcErr = s.agent.Client().RPC("Allocations.Checks", &args, &reply)
case useServerRPC:
rpcErr = s.agent.Server().RPC("Allocations.Checks", &args, &reply)
default:
rpcErr = CodedError(400, "No local Node and node_id not provided")
}
if rpcErr != nil {
if structs.IsErrNoNodeConn(rpcErr) || structs.IsErrUnknownAllocation(rpcErr) {
rpcErr = CodedError(404, rpcErr.Error())
}
}
return reply.Results, rpcErr
}
func (s *HTTPServer) allocExec(allocID string, resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Build the request and parse the ACL token
task := req.URL.Query().Get("task")
@@ -600,7 +637,9 @@ func (s *HTTPServer) execStreamImpl(ws *websocket.Conn, args *cstructs.AllocExec
// we won't return an error on ws close, but at least make it available in
// the logs so we can trace spurious disconnects
s.logger.Debug("alloc exec channel closed with error", "error", codedErr)
if codedErr != nil {
s.logger.Debug("alloc exec channel closed with error", "error", codedErr)
}
if isClosedError(codedErr) {
codedErr = nil

File diff suppressed because one or more lines are too long

View File

@@ -399,6 +399,7 @@ func (c *Command) IsValidConfig(config, cmdConfig *Config) bool {
}
for _, hn := range config.Client.HostNetworks {
// Ensure port range is valid
if _, err := structs.ParsePortRanges(hn.ReservedPorts); err != nil {
c.Ui.Error(fmt.Sprintf("host_network[%q].reserved_ports %q invalid: %v",
hn.Name, hn.ReservedPorts, err))

View File

@@ -20,6 +20,7 @@ import (
sockaddr "github.com/hashicorp/go-sockaddr"
"github.com/hashicorp/go-sockaddr/template"
client "github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/client/fingerprint"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/nomad"
"github.com/hashicorp/nomad/nomad/structs"
@@ -447,6 +448,19 @@ type ServerConfig struct {
// be collected by GC.
ACLTokenGCThreshold string `hcl:"acl_token_gc_threshold"`
// RootKeyGCInterval is how often we dispatch a job to GC
// encryption key metadata
RootKeyGCInterval string `hcl:"root_key_gc_interval"`
// RootKeyGCThreshold is how "old" encryption key metadata must be
// to be eligible for GC.
RootKeyGCThreshold string `hcl:"root_key_gc_threshold"`
// RootKeyRotationThreshold is how "old" an encryption key must be
// before it is automatically rotated on the next garbage
// collection interval.
RootKeyRotationThreshold string `hcl:"root_key_rotation_threshold"`
// HeartbeatGrace is the grace period beyond the TTL to account for network,
// processing delays and clock skew before marking a node as "down".
HeartbeatGrace time.Duration
@@ -519,6 +533,10 @@ type ServerConfig struct {
// This value is ignored.
DefaultSchedulerConfig *structs.SchedulerConfiguration `hcl:"default_scheduler_config"`
// PlanRejectionTracker configures the node plan rejection tracker that
// detects potentially bad nodes.
PlanRejectionTracker *PlanRejectionTracker `hcl:"plan_rejection_tracker"`
// EnableEventBroker configures whether this server's state store
// will generate events for its event stream.
EnableEventBroker *bool `hcl:"enable_event_broker"`
@@ -564,6 +582,53 @@ type RaftBoltConfig struct {
NoFreelistSync bool `hcl:"no_freelist_sync"`
}
// PlanRejectionTracker is used in servers to configure the plan rejection
// tracker.
type PlanRejectionTracker struct {
// Enabled controls if the plan rejection tracker is active or not.
Enabled *bool `hcl:"enabled"`
// NodeThreshold is the number of times a node can have plan rejections
// before it is marked as ineligible.
NodeThreshold int `hcl:"node_threshold"`
// NodeWindow is the time window used to track active plan rejections for
// nodes.
NodeWindow time.Duration
NodeWindowHCL string `hcl:"node_window" json:"-"`
// ExtraKeysHCL is used by hcl to surface unexpected keys
ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"`
}
func (p *PlanRejectionTracker) Merge(b *PlanRejectionTracker) *PlanRejectionTracker {
if p == nil {
return b
}
result := *p
if b == nil {
return &result
}
if b.Enabled != nil {
result.Enabled = b.Enabled
}
if b.NodeThreshold != 0 {
result.NodeThreshold = b.NodeThreshold
}
if b.NodeWindow != 0 {
result.NodeWindow = b.NodeWindow
}
if b.NodeWindowHCL != "" {
result.NodeWindowHCL = b.NodeWindowHCL
}
return &result
}
// Search is used in servers to configure search API options.
type Search struct {
// FuzzyEnabled toggles whether the FuzzySearch API is enabled. If not
@@ -939,6 +1004,7 @@ func DevConfig(mode *devModeConfig) *Config {
FunctionDenylist: client.DefaultTemplateFunctionDenylist,
DisableSandbox: false,
}
conf.Client.Options[fingerprint.TightenNetworkTimeoutsConfig] = "true"
conf.Client.BindWildcardDefaultHostNetwork = true
conf.Client.NomadServiceDiscovery = helper.BoolToPtr(true)
conf.Telemetry.PrometheusMetrics = true
@@ -1001,6 +1067,11 @@ func DefaultConfig() *Config {
EventBufferSize: helper.IntToPtr(100),
RaftProtocol: 3,
StartJoin: []string{},
PlanRejectionTracker: &PlanRejectionTracker{
Enabled: helper.BoolToPtr(false),
NodeThreshold: 100,
NodeWindow: 5 * time.Minute,
},
ServerJoin: &ServerJoin{
RetryJoin: []string{},
RetryInterval: 30 * time.Second,
@@ -1557,6 +1628,15 @@ func (s *ServerConfig) Merge(b *ServerConfig) *ServerConfig {
if b.ACLTokenGCThreshold != "" {
result.ACLTokenGCThreshold = b.ACLTokenGCThreshold
}
if b.RootKeyGCInterval != "" {
result.RootKeyGCInterval = b.RootKeyGCInterval
}
if b.RootKeyGCThreshold != "" {
result.RootKeyGCThreshold = b.RootKeyGCThreshold
}
if b.RootKeyRotationThreshold != "" {
result.RootKeyRotationThreshold = b.RootKeyRotationThreshold
}
if b.HeartbeatGrace != 0 {
result.HeartbeatGrace = b.HeartbeatGrace
}
@@ -1617,6 +1697,10 @@ func (s *ServerConfig) Merge(b *ServerConfig) *ServerConfig {
result.EventBufferSize = b.EventBufferSize
}
if b.PlanRejectionTracker != nil {
result.PlanRejectionTracker = result.PlanRejectionTracker.Merge(b.PlanRejectionTracker)
}
if b.DefaultSchedulerConfig != nil {
c := *b.DefaultSchedulerConfig
result.DefaultSchedulerConfig = &c

View File

@@ -43,9 +43,12 @@ func ParseConfigFile(path string) (*Config, error) {
VaultRetry: &client.RetryConfig{},
},
},
Server: &ServerConfig{
PlanRejectionTracker: &PlanRejectionTracker{},
ServerJoin: &ServerJoin{},
},
ACL: &ACLConfig{},
Audit: &config.AuditConfig{},
Server: &ServerConfig{ServerJoin: &ServerJoin{}},
Consul: &config.ConsulConfig{},
Autopilot: &config.AutopilotConfig{},
Telemetry: &Telemetry{},
@@ -54,7 +57,7 @@ func ParseConfigFile(path string) (*Config, error) {
err = hcl.Decode(c, buf.String())
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to decode HCL file %s: %w", path, err)
}
// convert strings to time.Durations
@@ -68,6 +71,7 @@ func ParseConfigFile(path string) (*Config, error) {
{"server.heartbeat_grace", &c.Server.HeartbeatGrace, &c.Server.HeartbeatGraceHCL, nil},
{"server.min_heartbeat_ttl", &c.Server.MinHeartbeatTTL, &c.Server.MinHeartbeatTTLHCL, nil},
{"server.failover_heartbeat_ttl", &c.Server.FailoverHeartbeatTTL, &c.Server.FailoverHeartbeatTTLHCL, nil},
{"server.plan_rejection_tracker.node_window", &c.Server.PlanRejectionTracker.NodeWindow, &c.Server.PlanRejectionTracker.NodeWindowHCL, nil},
{"server.retry_interval", &c.Server.RetryInterval, &c.Server.RetryIntervalHCL, nil},
{"server.server_join.retry_interval", &c.Server.ServerJoin.RetryInterval, &c.Server.ServerJoin.RetryIntervalHCL, nil},
{"consul.timeout", &c.Consul.Timeout, &c.Consul.TimeoutHCL, nil},

View File

@@ -127,6 +127,12 @@ var basicConfig = &Config{
EncryptKey: "abc",
EnableEventBroker: helper.BoolToPtr(false),
EventBufferSize: helper.IntToPtr(200),
PlanRejectionTracker: &PlanRejectionTracker{
Enabled: helper.BoolToPtr(true),
NodeThreshold: 100,
NodeWindow: 41 * time.Minute,
NodeWindowHCL: "41m",
},
ServerJoin: &ServerJoin{
RetryJoin: []string{"1.1.1.1", "2.2.2.2"},
RetryInterval: time.Duration(15) * time.Second,
@@ -218,6 +224,7 @@ var basicConfig = &Config{
AutoAdvertise: &trueValue,
ChecksUseAdvertise: &trueValue,
Timeout: 5 * time.Second,
TimeoutHCL: "5s",
},
Vault: &config.VaultConfig{
Addr: "127.0.0.1:9500",
@@ -425,7 +432,10 @@ func TestConfig_ParseMerge(t *testing.T) {
actual, err := ParseConfigFile(path)
require.NoError(t, err)
require.Equal(t, basicConfig.Client, actual.Client)
// The Vault connection retry interval is an internal only configuration
// option, and therefore needs to be added here to ensure the test passes.
actual.Vault.ConnectionRetryIntv = config.DefaultVaultConnectRetryIntv
require.Equal(t, basicConfig, actual)
oldDefault := &Config{
Consul: config.DefaultConsulConfig(),
@@ -436,8 +446,7 @@ func TestConfig_ParseMerge(t *testing.T) {
Audit: &config.AuditConfig{},
}
merged := oldDefault.Merge(actual)
require.Equal(t, basicConfig.Client, merged.Client)
require.Equal(t, basicConfig, merged)
}
func TestConfig_Parse(t *testing.T) {
@@ -545,6 +554,9 @@ func (c *Config) addDefaults() {
if c.Server.ServerJoin == nil {
c.Server.ServerJoin = &ServerJoin{}
}
if c.Server.PlanRejectionTracker == nil {
c.Server.PlanRejectionTracker = &PlanRejectionTracker{}
}
}
// Tests for a panic parsing json with an object of exactly
@@ -622,6 +634,11 @@ var sample0 = &Config{
RetryJoin: []string{"10.0.0.101", "10.0.0.102", "10.0.0.103"},
EncryptKey: "sHck3WL6cxuhuY7Mso9BHA==",
ServerJoin: &ServerJoin{},
PlanRejectionTracker: &PlanRejectionTracker{
NodeThreshold: 100,
NodeWindow: 31 * time.Minute,
NodeWindowHCL: "31m",
},
},
ACL: &ACLConfig{
Enabled: true,
@@ -712,6 +729,11 @@ var sample1 = &Config{
RetryJoin: []string{"10.0.0.101", "10.0.0.102", "10.0.0.103"},
EncryptKey: "sHck3WL6cxuhuY7Mso9BHA==",
ServerJoin: &ServerJoin{},
PlanRejectionTracker: &PlanRejectionTracker{
NodeThreshold: 100,
NodeWindow: 31 * time.Minute,
NodeWindowHCL: "31m",
},
},
ACL: &ACLConfig{
Enabled: true,

View File

@@ -148,6 +148,11 @@ func TestConfig_Merge(t *testing.T) {
UpgradeVersion: "foo",
EnableEventBroker: helper.BoolToPtr(false),
EventBufferSize: helper.IntToPtr(0),
PlanRejectionTracker: &PlanRejectionTracker{
Enabled: helper.BoolToPtr(true),
NodeThreshold: 100,
NodeWindow: 11 * time.Minute,
},
},
ACL: &ACLConfig{
Enabled: true,
@@ -345,6 +350,11 @@ func TestConfig_Merge(t *testing.T) {
UpgradeVersion: "bar",
EnableEventBroker: helper.BoolToPtr(true),
EventBufferSize: helper.IntToPtr(100),
PlanRejectionTracker: &PlanRejectionTracker{
Enabled: helper.BoolToPtr(true),
NodeThreshold: 100,
NodeWindow: 11 * time.Minute,
},
},
ACL: &ACLConfig{
Enabled: true,

View File

@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/armon/go-metrics"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/gorilla/handlers"
"github.com/gorilla/websocket"
@@ -22,6 +23,7 @@ import (
"github.com/hashicorp/go-msgpack/codec"
multierror "github.com/hashicorp/go-multierror"
"github.com/rs/cors"
"golang.org/x/time/rate"
"github.com/hashicorp/nomad/acl"
"github.com/hashicorp/nomad/helper/noxssrw"
@@ -57,13 +59,16 @@ var (
// tag isn't enabled
stubHTML = "<html><p>Nomad UI is disabled</p></html>"
// allowCORS sets permissive CORS headers for a handler
allowCORS = cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"HEAD", "GET"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
// allowCORSWithMethods sets permissive CORS headers for a handler, used by
// wrapCORS and wrapCORSWithMethods
allowCORSWithMethods = func(methods ...string) *cors.Cors {
return cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: methods,
AllowedHeaders: []string{"*"},
AllowCredentials: true,
})
}
)
type handlerFn func(resp http.ResponseWriter, req *http.Request) (interface{}, error)
@@ -153,7 +158,7 @@ func NewHTTPServers(agent *Agent, config *Config) ([]*HTTPServer, error) {
httpServer := http.Server{
Addr: srv.Addr,
Handler: handlers.CompressHandler(srv.mux),
ConnState: makeConnState(config.TLSConfig.EnableHTTP, handshakeTimeout, maxConns),
ConnState: makeConnState(config.TLSConfig.EnableHTTP, handshakeTimeout, maxConns, srv.logger),
ErrorLog: newHTTPServerLogger(srv.logger),
}
@@ -210,13 +215,12 @@ func NewHTTPServers(agent *Agent, config *Config) ([]*HTTPServer, error) {
//
// If limit > 0, a per-address connection limit will be enabled regardless of
// TLS. If connLimit == 0 there is no connection limit.
func makeConnState(isTLS bool, handshakeTimeout time.Duration, connLimit int) func(conn net.Conn, state http.ConnState) {
func makeConnState(isTLS bool, handshakeTimeout time.Duration, connLimit int, logger log.Logger) func(conn net.Conn, state http.ConnState) {
connLimiter := connLimiter(connLimit, logger)
if !isTLS || handshakeTimeout == 0 {
if connLimit > 0 {
// Still return the connection limiter
return connlimit.NewLimiter(connlimit.Config{
MaxConnsPerClientIP: connLimit,
}).HTTPConnStateFunc()
return connLimiter
}
return nil
}
@@ -224,9 +228,6 @@ func makeConnState(isTLS bool, handshakeTimeout time.Duration, connLimit int) fu
if connLimit > 0 {
// Return conn state callback with connection limiting and a
// handshake timeout.
connLimiter := connlimit.NewLimiter(connlimit.Config{
MaxConnsPerClientIP: connLimit,
}).HTTPConnStateFunc()
return func(conn net.Conn, state http.ConnState) {
switch state {
@@ -265,6 +266,35 @@ func makeConnState(isTLS bool, handshakeTimeout time.Duration, connLimit int) fu
}
}
// connLimiter returns a connection-limiter function with a rate-limited 429-response error handler.
// The rate-limit prevents the TLS handshake necessary to write the HTTP response
// from consuming too many server resources.
func connLimiter(connLimit int, logger log.Logger) func(conn net.Conn, state http.ConnState) {
// Global rate-limit of 10 responses per second with a 100-response burst.
limiter := rate.NewLimiter(10, 100)
tooManyConnsMsg := "Your IP is issuing too many concurrent connections, please rate limit your calls\n"
tooManyRequestsResponse := []byte(fmt.Sprintf("HTTP/1.1 429 Too Many Requests\r\n"+
"Content-Type: text/plain\r\n"+
"Content-Length: %d\r\n"+
"Connection: close\r\n\r\n%s", len(tooManyConnsMsg), tooManyConnsMsg))
return connlimit.NewLimiter(connlimit.Config{
MaxConnsPerClientIP: connLimit,
}).HTTPConnStateFuncWithErrorHandler(func(err error, conn net.Conn) {
if err == connlimit.ErrPerClientIPLimitReached {
metrics.IncrCounter([]string{"nomad", "agent", "http", "exceeded"}, 1)
if n := limiter.Reserve(); n.Delay() == 0 {
logger.Warn("Too many concurrent connections", "address", conn.RemoteAddr().String(), "limit", connLimit)
conn.SetDeadline(time.Now().Add(10 * time.Millisecond))
conn.Write(tooManyRequestsResponse)
} else {
n.Cancel()
}
}
conn.Close()
})
}
// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted
// connections. It's used by NewHttpServer so
// dead TCP connections eventually go away.
@@ -394,9 +424,9 @@ func (s HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/search/fuzzy", s.wrap(s.FuzzySearchRequest))
s.mux.HandleFunc("/v1/search", s.wrap(s.SearchRequest))
s.mux.HandleFunc("/v1/operator/license", s.wrap(s.LicenseRequest))
s.mux.HandleFunc("/v1/operator/raft/", s.wrap(s.OperatorRequest))
s.mux.HandleFunc("/v1/operator/keyring/", s.wrap(s.KeyringRequest))
s.mux.HandleFunc("/v1/operator/autopilot/configuration", s.wrap(s.OperatorAutopilotConfiguration))
s.mux.HandleFunc("/v1/operator/autopilot/health", s.wrap(s.OperatorServerHealth))
s.mux.HandleFunc("/v1/operator/snapshot", s.wrap(s.SnapshotRequest))
@@ -407,10 +437,14 @@ func (s HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/operator/scheduler/configuration", s.wrap(s.OperatorSchedulerConfiguration))
s.mux.HandleFunc("/v1/event/stream", s.wrap(s.EventStream))
s.mux.HandleFunc("/v1/namespaces", s.wrap(s.NamespacesRequest))
s.mux.HandleFunc("/v1/namespace", s.wrap(s.NamespaceCreateRequest))
s.mux.HandleFunc("/v1/namespace/", s.wrap(s.NamespaceSpecificRequest))
s.mux.Handle("/v1/vars", wrapCORS(s.wrap(s.SecureVariablesListRequest)))
s.mux.Handle("/v1/var/", wrapCORSWithAllowedMethods(s.wrap(s.SecureVariableSpecificRequest), "HEAD", "GET", "PUT", "DELETE"))
uiConfigEnabled := s.agent.config.UI != nil && s.agent.config.UI.Enabled
if uiEnabled && uiConfigEnabled {
@@ -901,7 +935,14 @@ func (s *HTTPServer) wrapUntrustedContent(handler handlerFn) handlerFn {
}
}
// wrapCORS wraps a HandlerFunc in allowCORS and returns a http.Handler
// wrapCORS wraps a HandlerFunc in allowCORS with read ("HEAD", "GET") methods
// and returns a http.Handler
func wrapCORS(f func(http.ResponseWriter, *http.Request)) http.Handler {
return allowCORS.Handler(http.HandlerFunc(f))
return wrapCORSWithAllowedMethods(f, "HEAD", "GET")
}
// wrapCORSWithAllowedMethods wraps a HandlerFunc in an allowCORS with the given
// method list and returns a http.Handler
func wrapCORSWithAllowedMethods(f func(http.ResponseWriter, *http.Request), methods ...string) http.Handler {
return allowCORSWithMethods(methods...).Handler(http.HandlerFunc(f))
}

View File

@@ -1253,42 +1253,17 @@ func TestHTTPServer_Limits_OK(t *testing.T) {
// Create a new connection that will go over the connection limit.
limitConn, err := dial(t, addr, useTLS)
require.NoError(t, err)
// At this point, the state of the connection + handshake are up in the
// air.
//
// 1) dial failed, handshake never started
// => Conn is nil + io.EOF
// 2) dial completed, handshake failed
// => Conn is malformed + (net.OpError OR io.EOF)
// 3) dial completed, handshake succeeded
// => Conn is not nil + no error, however using the Conn should
// result in io.EOF
//
// At no point should Nomad actually write through the limited Conn.
if limitConn == nil || err != nil {
// Case 1 or Case 2 - returned Conn is useless and the error should
// be one of:
// "EOF"
// "closed network connection"
// "connection reset by peer"
msg := err.Error()
acceptable := strings.Contains(msg, "EOF") ||
strings.Contains(msg, "closed network connection") ||
strings.Contains(msg, "connection reset by peer")
require.True(t, acceptable)
} else {
// Case 3 - returned Conn is usable, but Nomad should not write
// anything before closing it.
buf := make([]byte, bufSize)
deadline := time.Now().Add(10 * time.Second)
require.NoError(t, limitConn.SetReadDeadline(deadline))
n, err := limitConn.Read(buf)
require.Equal(t, io.EOF, err)
require.Zero(t, n)
require.NoError(t, limitConn.Close())
}
response := "HTTP/1.1 429"
buf := make([]byte, len(response))
deadline := time.Now().Add(10 * time.Second)
require.NoError(t, limitConn.SetReadDeadline(deadline))
n, err := limitConn.Read(buf)
require.Equal(t, response, string(buf))
require.Nil(t, err)
require.Equal(t, len(response), n)
require.NoError(t, limitConn.Close())
// Assert existing connections are ok
require.Len(t, errCh, 0)

View File

@@ -2921,7 +2921,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
TaggedAddresses: map[string]string{
"wan": "1.2.3.4",
},
OnUpdate: "require_healthy",
OnUpdate: structs.OnUpdateRequireHealthy,
Checks: []*structs.ServiceCheck{
{
Name: "bar",
@@ -2945,7 +2945,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
IgnoreWarnings: true,
},
TaskName: "task1",
OnUpdate: "require_healthy",
OnUpdate: structs.OnUpdateRequireHealthy,
SuccessBeforePassing: 2,
FailuresBeforeCritical: 3,
},
@@ -3015,7 +3015,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
Meta: map[string]string{
"servicemeta": "foobar",
},
OnUpdate: "require_healthy",
OnUpdate: structs.OnUpdateRequireHealthy,
Checks: []*structs.ServiceCheck{
{
Name: "bar",
@@ -3038,7 +3038,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
Grace: 11 * time.Second,
IgnoreWarnings: true,
},
OnUpdate: "require_healthy",
OnUpdate: structs.OnUpdateRequireHealthy,
},
{
Name: "check2",
@@ -3050,7 +3050,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
Limit: 4,
Grace: 11 * time.Second,
},
OnUpdate: "require_healthy",
OnUpdate: structs.OnUpdateRequireHealthy,
},
},
},

View File

@@ -0,0 +1,134 @@
package agent
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/nomad/structs"
)
// KeyringRequest is used route operator/raft API requests to the implementing
// functions.
func (s *HTTPServer) KeyringRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
path := strings.TrimPrefix(req.URL.Path, "/v1/operator/keyring/")
switch {
case strings.HasPrefix(path, "keys"):
switch req.Method {
case http.MethodGet:
return s.keyringListRequest(resp, req)
case http.MethodPost, http.MethodPut:
return s.keyringUpsertRequest(resp, req)
default:
return nil, CodedError(405, ErrInvalidMethod)
}
case strings.HasPrefix(path, "key"):
keyID := strings.TrimPrefix(req.URL.Path, "/v1/operator/keyring/key/")
switch req.Method {
case http.MethodDelete:
return s.keyringDeleteRequest(resp, req, keyID)
default:
return nil, CodedError(405, ErrInvalidMethod)
}
case strings.HasPrefix(path, "rotate"):
return s.keyringRotateRequest(resp, req)
default:
return nil, CodedError(405, ErrInvalidMethod)
}
}
func (s *HTTPServer) keyringListRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
args := structs.KeyringListRootKeyMetaRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
var out structs.KeyringListRootKeyMetaResponse
if err := s.agent.RPC("Keyring.List", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
if out.Keys == nil {
out.Keys = make([]*structs.RootKeyMeta, 0)
}
return out.Keys, nil
}
func (s *HTTPServer) keyringRotateRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
args := structs.KeyringRotateRootKeyRequest{}
s.parseWriteRequest(req, &args.WriteRequest)
query := req.URL.Query()
switch query.Get("algo") {
case string(structs.EncryptionAlgorithmAES256GCM):
args.Algorithm = structs.EncryptionAlgorithmAES256GCM
}
if _, ok := query["full"]; ok {
args.Full = true
}
var out structs.KeyringRotateRootKeyResponse
if err := s.agent.RPC("Keyring.Rotate", &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return out, nil
}
func (s *HTTPServer) keyringUpsertRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
var key api.RootKey
if err := decodeBody(req, &key); err != nil {
return nil, CodedError(400, err.Error())
}
if key.Meta == nil {
return nil, CodedError(400, "decoded key did not include metadata")
}
const keyLen = 32
decodedKey := make([]byte, keyLen)
_, err := base64.StdEncoding.Decode(decodedKey, []byte(key.Key)[:keyLen])
if err != nil {
return nil, CodedError(400, fmt.Sprintf("could not decode key: %v", err))
}
args := structs.KeyringUpdateRootKeyRequest{
RootKey: &structs.RootKey{
Key: decodedKey,
Meta: &structs.RootKeyMeta{
KeyID: key.Meta.KeyID,
Algorithm: structs.EncryptionAlgorithm(key.Meta.Algorithm),
State: structs.RootKeyState(key.Meta.State),
},
},
}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.KeyringUpdateRootKeyResponse
if err := s.agent.RPC("Keyring.Update", &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return out, nil
}
func (s *HTTPServer) keyringDeleteRequest(resp http.ResponseWriter, req *http.Request, keyID string) (interface{}, error) {
args := structs.KeyringDeleteRootKeyRequest{KeyID: keyID}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.KeyringDeleteRootKeyResponse
if err := s.agent.RPC("Keyring.Delete", &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return out, nil
}

View File

@@ -0,0 +1,103 @@
package agent
import (
"encoding/base64"
"math/rand"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/structs"
)
func TestHTTP_Keyring_CRUD(t *testing.T) {
ci.Parallel(t)
httpTest(t, nil, func(s *TestAgent) {
respW := httptest.NewRecorder()
// Rotate
req, err := http.NewRequest(http.MethodPut, "/v1/operator/keyring/rotate", nil)
require.NoError(t, err)
obj, err := s.Server.KeyringRequest(respW, req)
require.NoError(t, err)
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
rotateResp := obj.(structs.KeyringRotateRootKeyResponse)
require.NotNil(t, rotateResp.Key)
require.True(t, rotateResp.Key.Active())
newID1 := rotateResp.Key.KeyID
// List
req, err = http.NewRequest(http.MethodGet, "/v1/operator/keyring/keys", nil)
require.NoError(t, err)
obj, err = s.Server.KeyringRequest(respW, req)
require.NoError(t, err)
listResp := obj.([]*structs.RootKeyMeta)
require.Len(t, listResp, 2)
for _, key := range listResp {
if key.KeyID == newID1 {
require.True(t, key.Active(), "new key should be active")
} else {
require.False(t, key.Active(), "initial key should be inactive")
}
}
// Update
keyMeta := rotateResp.Key
keyBuf := make([]byte, 32)
rand.Read(keyBuf)
encodedKey := base64.StdEncoding.EncodeToString(keyBuf)
newID2 := uuid.Generate()
key := &api.RootKey{
Meta: &api.RootKeyMeta{
State: api.RootKeyStateActive,
KeyID: newID2,
Algorithm: api.EncryptionAlgorithm(keyMeta.Algorithm),
},
Key: encodedKey,
}
reqBuf := encodeReq(key)
req, err = http.NewRequest(http.MethodPut, "/v1/operator/keyring/keys", reqBuf)
require.NoError(t, err)
obj, err = s.Server.KeyringRequest(respW, req)
require.NoError(t, err)
updateResp := obj.(structs.KeyringUpdateRootKeyResponse)
require.NotNil(t, updateResp)
// Delete the old key and verify its gone
id := rotateResp.Key.KeyID
req, err = http.NewRequest(http.MethodDelete, "/v1/operator/keyring/key/"+id, nil)
require.NoError(t, err)
obj, err = s.Server.KeyringRequest(respW, req)
require.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, "/v1/operator/keyring/keys", nil)
require.NoError(t, err)
obj, err = s.Server.KeyringRequest(respW, req)
require.NoError(t, err)
listResp = obj.([]*structs.RootKeyMeta)
require.Len(t, listResp, 2)
for _, key := range listResp {
require.NotEqual(t, newID1, key.KeyID)
if key.KeyID == newID2 {
require.True(t, key.Active(), "new key should be active")
} else {
require.False(t, key.Active(), "initial key should be inactive")
}
}
})
}

View File

@@ -622,3 +622,282 @@ func TestHTTP_FuzzySearch_AllContext(t *testing.T) {
require.Equal(t, "8000", header(respW, "X-Nomad-Index"))
})
}
func TestHTTP_PrefixSearch_SecureVariables(t *testing.T) {
ci.Parallel(t)
testPath := "alpha/beta/charlie"
testPathPrefix := "alpha/beta"
httpTest(t, nil, func(s *TestAgent) {
sv := mock.SecureVariableEncrypted()
state := s.Agent.server.State()
sv.Path = testPath
err := state.UpsertSecureVariables(structs.MsgTypeTestSetup, 8000, []*structs.SecureVariableEncrypted{sv})
require.NoError(t, err)
data := structs.SearchRequest{Prefix: testPathPrefix, Context: structs.SecureVariables}
req, err := http.NewRequest("POST", "/v1/search", encodeReq(data))
require.NoError(t, err)
respW := httptest.NewRecorder()
resp, err := s.Server.SearchRequest(respW, req)
require.NoError(t, err)
res := resp.(structs.SearchResponse)
matchedVars := res.Matches[structs.SecureVariables]
require.Len(t, matchedVars, 1)
require.Equal(t, testPath, matchedVars[0])
require.Equal(t, "8000", header(respW, "X-Nomad-Index"))
})
}
func TestHTTP_FuzzySearch_SecureVariables(t *testing.T) {
ci.Parallel(t)
testPath := "alpha/beta/charlie"
testPathText := "beta"
httpTest(t, nil, func(s *TestAgent) {
state := s.Agent.server.State()
sv := mock.SecureVariableEncrypted()
sv.Path = testPath
err := state.UpsertSecureVariables(structs.MsgTypeTestSetup, 8000, []*structs.SecureVariableEncrypted{sv})
require.NoError(t, err)
data := structs.FuzzySearchRequest{Text: testPathText, Context: structs.SecureVariables}
req, err := http.NewRequest("POST", "/v1/search/", encodeReq(data))
require.NoError(t, err)
respW := httptest.NewRecorder()
resp, err := s.Server.FuzzySearchRequest(respW, req)
require.NoError(t, err)
res := resp.(structs.FuzzySearchResponse)
matchedVars := res.Matches[structs.SecureVariables]
require.Len(t, matchedVars, 1)
require.Equal(t, testPath, matchedVars[0].ID)
require.Equal(t, []string{
"default", testPath,
}, matchedVars[0].Scope)
require.Equal(t, "8000", header(respW, "X-Nomad-Index"))
})
}
func TestHTTP_PrefixSearch_SecureVariables_ACL(t *testing.T) {
ci.Parallel(t)
testPath := "alpha/beta/charlie"
testPathPrefix := "alpha/beta"
httpACLTest(t, nil, func(s *TestAgent) {
state := s.Agent.server.State()
ns := mock.Namespace()
sv1 := mock.SecureVariableEncrypted()
sv1.Path = testPath
sv2 := sv1.Copy()
sv2.Namespace = ns.Name
_ = state.UpsertNamespaces(7000, []*structs.Namespace{ns})
_ = state.UpsertSecureVariables(structs.MsgTypeTestSetup, 8000, []*structs.SecureVariableEncrypted{sv1})
_ = state.UpsertSecureVariables(structs.MsgTypeTestSetup, 8001, []*structs.SecureVariableEncrypted{&sv2})
rootToken := s.RootToken
defNSToken := mock.CreatePolicyAndToken(t, state, 8002, "default",
mock.NamespacePolicyWithSecureVariables(
"default", "read", []string{},
map[string][]string{"*": []string{"read", "list"}}))
ns1NSToken := mock.CreatePolicyAndToken(t, state, 8004, "ns-"+ns.Name,
mock.NamespacePolicyWithSecureVariables(
ns.Name, "read", []string{},
map[string][]string{"*": []string{"read", "list"}}))
denyToken := mock.CreatePolicyAndToken(t, state, 8006, "none",
mock.NamespacePolicy("default", "deny", nil))
testCases := []struct {
desc string
token *structs.ACLToken
namespace string
expectedCount int
expectedNamespaces []string
expectedErr string
}{
{
desc: "management token",
token: rootToken,
namespace: "*",
expectedCount: 2,
expectedNamespaces: []string{"default", ns.Name},
},
{
desc: "default ns token",
token: defNSToken,
namespace: "default",
expectedCount: 1,
expectedNamespaces: []string{"default"},
},
{
desc: "ns specific token",
token: ns1NSToken,
namespace: ns.Name,
expectedCount: 1,
expectedNamespaces: []string{ns.Name},
},
{
desc: "denied token",
token: denyToken,
namespace: "default",
expectedCount: 0,
expectedErr: structs.ErrPermissionDenied.Error(),
},
}
for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) {
tC := tC
data := structs.SearchRequest{
Prefix: testPathPrefix,
Context: structs.SecureVariables,
QueryOptions: structs.QueryOptions{
AuthToken: tC.token.SecretID,
Namespace: tC.namespace,
},
}
req, err := http.NewRequest("POST", "/v1/search", encodeReq(data))
require.NoError(t, err)
respW := httptest.NewRecorder()
resp, err := s.Server.SearchRequest(respW, req)
if tC.expectedErr != "" {
require.Error(t, err)
require.Equal(t, tC.expectedErr, err.Error())
return
}
require.NoError(t, err)
res := resp.(structs.SearchResponse)
matchedVars := res.Matches[structs.SecureVariables]
require.Len(t, matchedVars, tC.expectedCount)
for _, mv := range matchedVars {
require.Equal(t, testPath, mv)
}
require.Equal(t, "8001", header(respW, "X-Nomad-Index"))
})
}
})
}
func TestHTTP_FuzzySearch_SecureVariables_ACL(t *testing.T) {
ci.Parallel(t)
testPath := "alpha/beta/charlie"
testPathText := "beta"
httpACLTest(t, nil, func(s *TestAgent) {
state := s.Agent.server.State()
ns := mock.Namespace()
sv1 := mock.SecureVariableEncrypted()
sv1.Path = testPath
sv2 := sv1.Copy()
sv2.Namespace = ns.Name
_ = state.UpsertNamespaces(7000, []*structs.Namespace{mock.Namespace()})
_ = state.UpsertSecureVariables(structs.MsgTypeTestSetup, 8000, []*structs.SecureVariableEncrypted{sv1})
_ = state.UpsertSecureVariables(structs.MsgTypeTestSetup, 8001, []*structs.SecureVariableEncrypted{&sv2})
rootToken := s.RootToken
defNSToken := mock.CreatePolicyAndToken(t, state, 8002, "default", mock.NamespacePolicy("default", "read", nil))
ns1NSToken := mock.CreatePolicyAndToken(t, state, 8004, "ns-"+ns.Name, mock.NamespacePolicy(ns.Name, "read", nil))
denyToken := mock.CreatePolicyAndToken(t, state, 8006, "none", mock.NamespacePolicy("default", "deny", nil))
type testCase struct {
desc string
token *structs.ACLToken
namespace string
expectedCount int
expectedNamespaces []string
expectedErr string
}
testCases := []testCase{
{
desc: "management token",
token: rootToken,
expectedCount: 2,
expectedNamespaces: []string{"default", ns.Name},
},
{
desc: "default ns token",
token: defNSToken,
expectedCount: 1,
expectedNamespaces: []string{"default"},
},
{
desc: "ns specific token",
token: ns1NSToken,
expectedCount: 1,
expectedNamespaces: []string{ns.Name},
},
{
desc: "denied token",
token: denyToken,
expectedCount: 0,
// You would think that this should error out, but when it is
// the wildcard namespace, objects that fail the access check
// are filtered out rather than throwing a permissions error.
},
{
desc: "denied token",
token: denyToken,
namespace: "default",
expectedCount: 0,
expectedErr: structs.ErrPermissionDenied.Error(),
},
}
tcNS := func(tC testCase) string {
if tC.namespace == "" {
return "*"
}
return tC.namespace
}
for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) {
data := structs.FuzzySearchRequest{
Text: testPathText,
Context: structs.SecureVariables,
QueryOptions: structs.QueryOptions{
AuthToken: tC.token.SecretID,
Namespace: tcNS(tC),
},
}
req, err := http.NewRequest("POST", "/v1/search/fuzzy", encodeReq(data))
require.NoError(t, err)
setToken(req, tC.token)
respW := httptest.NewRecorder()
resp, err := s.Server.FuzzySearchRequest(respW, req)
if tC.expectedErr != "" {
require.Error(t, err)
require.Equal(t, tC.expectedErr, err.Error())
return
}
res := resp.(structs.FuzzySearchResponse)
matchedVars := res.Matches[structs.SecureVariables]
require.Len(t, matchedVars, tC.expectedCount)
for _, mv := range matchedVars {
require.Equal(t, testPath, mv.ID)
require.Len(t, mv.Scope, 2)
require.Contains(t, tC.expectedNamespaces, mv.Scope[0])
require.Equal(t, "8001", header(respW, "X-Nomad-Index"))
}
})
}
})
}

View File

@@ -0,0 +1,178 @@
package agent
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/hashicorp/nomad/nomad/structs"
)
func (s *HTTPServer) SecureVariablesListRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "GET" {
return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod)
}
args := structs.SecureVariablesListRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
var out structs.SecureVariablesListResponse
if err := s.agent.RPC(structs.SecureVariablesListRPCMethod, &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
if out.Data == nil {
out.Data = make([]*structs.SecureVariableMetadata, 0)
}
return out.Data, nil
}
func (s *HTTPServer) SecureVariableSpecificRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
path := strings.TrimPrefix(req.URL.Path, "/v1/var/")
if len(path) == 0 {
return nil, CodedError(http.StatusBadRequest, "missing secure variable path")
}
switch req.Method {
case http.MethodGet:
return s.secureVariableQuery(resp, req, path)
case http.MethodPut, http.MethodPost:
return s.secureVariableUpsert(resp, req, path)
case http.MethodDelete:
return s.secureVariableDelete(resp, req, path)
default:
return nil, CodedError(http.StatusBadRequest, ErrInvalidMethod)
}
}
func (s *HTTPServer) secureVariableQuery(resp http.ResponseWriter, req *http.Request,
path string) (interface{}, error) {
args := structs.SecureVariablesReadRequest{
Path: path,
}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
var out structs.SecureVariablesReadResponse
if err := s.agent.RPC(structs.SecureVariablesReadRPCMethod, &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
if out.Data == nil {
return nil, CodedError(http.StatusNotFound, "secure variable not found")
}
return out.Data, nil
}
func (s *HTTPServer) secureVariableUpsert(resp http.ResponseWriter, req *http.Request,
path string) (interface{}, error) {
// Parse the SecureVariable
var SecureVariable structs.SecureVariableDecrypted
if err := decodeBody(req, &SecureVariable); err != nil {
return nil, CodedError(http.StatusBadRequest, err.Error())
}
if len(SecureVariable.Items) == 0 {
return nil, CodedError(http.StatusBadRequest, "secure variable missing required Items object")
}
SecureVariable.Path = path
// This function always makes an upsert request with length of 1, which is
// an important proviso for when we check for conflicts and return them
args := structs.SecureVariablesUpsertRequest{
Data: []*structs.SecureVariableDecrypted{&SecureVariable},
}
s.parseWriteRequest(req, &args.WriteRequest)
if err := parseCAS(req, &args); err != nil {
return nil, err
}
var out structs.SecureVariablesUpsertResponse
if err := s.agent.RPC(structs.SecureVariablesUpsertRPCMethod, &args, &out); err != nil {
// This handles the cases where there is an error in the CAS checking
// function that renders it unable to return the conflicting variable
// so it returns a text error. We can at least consider these unknown
// moments to be CAS violations
if strings.Contains(err.Error(), "cas error:") {
resp.WriteHeader(http.StatusConflict)
}
// Otherwise it's a non-CAS error
setIndex(resp, out.WriteMeta.Index)
return nil, err
}
// As noted earlier, the upsert request generated by this endpoint always
// has length of 1, so we expect a non-Nil Conflicts slice to have len(1).
// We then extract the conflict value at index 0
if len(out.Conflicts) == 1 {
setIndex(resp, out.Conflicts[0].ModifyIndex)
resp.WriteHeader(http.StatusConflict)
return out.Conflicts[0], nil
}
// Finally, we know that this is a success response, send it to the caller
setIndex(resp, out.WriteMeta.Index)
return nil, nil
}
func (s *HTTPServer) secureVariableDelete(resp http.ResponseWriter, req *http.Request,
path string) (interface{}, error) {
args := structs.SecureVariablesDeleteRequest{
Path: path,
}
s.parseWriteRequest(req, &args.WriteRequest)
if err := parseCAS(req, &args); err != nil {
return nil, err
}
var out structs.SecureVariablesDeleteResponse
if err := s.agent.RPC(structs.SecureVariablesDeleteRPCMethod, &args, &out); err != nil {
// This handles the cases where there is an error in the CAS checking
// function that renders it unable to return the conflicting variable
// so it returns a text error. We can at least consider these unknown
// moments to be CAS violations
if strings.HasPrefix(err.Error(), "cas error:") {
resp.WriteHeader(http.StatusConflict)
}
setIndex(resp, out.WriteMeta.Index)
return nil, err
}
// If the CAS validation can decode the conflicting value, Conflict is
// non-Nil. Write out a 409 Conflict response.
if out.Conflict != nil {
setIndex(resp, out.Conflict.ModifyIndex)
resp.WriteHeader(http.StatusConflict)
return out.Conflict, nil
}
// Finally, we know that this is a success response, send it to the caller
setIndex(resp, out.WriteMeta.Index)
resp.WriteHeader(http.StatusNoContent)
return nil, nil
}
func parseCAS(req *http.Request, rpc CheckIndexSetter) error {
if cq := req.URL.Query().Get("cas"); cq != "" {
ci, err := strconv.ParseUint(cq, 10, 64)
if err != nil {
return CodedError(http.StatusBadRequest, fmt.Sprintf("can not parse cas: %v", err))
}
rpc.SetCheckIndex(ci)
}
return nil
}
type CheckIndexSetter interface {
SetCheckIndex(uint64)
}

View File

@@ -0,0 +1,535 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/stretchr/testify/require"
)
var (
cb = func(c *Config) {
var ns int
ns = 0
c.LogLevel = "ERROR"
c.Server.NumSchedulers = &ns
}
)
func TestHTTP_SecureVariables(t *testing.T) {
ci.Parallel(t)
httpTest(t, cb, func(s *TestAgent) {
// These tests are run against the same running server in order to reduce
// the costs of server startup and allow as much parallelization as possible
// given the port reuse issue that we have seen with the current freeport
t.Run("error_badverb_list", func(t *testing.T) {
req, err := http.NewRequest("LOLWUT", "/v1/vars", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
_, err = s.Server.SecureVariablesListRequest(respW, req)
require.EqualError(t, err, ErrInvalidMethod)
})
t.Run("error_parse_list", func(t *testing.T) {
req, err := http.NewRequest("GET", "/v1/vars?wait=99a", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
_, _ = s.Server.SecureVariablesListRequest(respW, req)
require.Equal(t, http.StatusBadRequest, respW.Code)
require.Equal(t, "Invalid wait time", string(respW.Body.Bytes()))
})
t.Run("error_rpc_list", func(t *testing.T) {
req, err := http.NewRequest("GET", "/v1/vars?region=bad", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariablesListRequest(respW, req)
require.EqualError(t, err, "No path to region")
require.Nil(t, obj)
})
t.Run("list", func(t *testing.T) {
// Test the empty list case
req, err := http.NewRequest("GET", "/v1/vars", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariablesListRequest(respW, req)
require.NoError(t, err)
// add vars and test a populated backend
svMap := mock.SecureVariables(4, 4)
svs := svMap.List()
svs[3].Path = svs[0].Path + "/child"
for _, sv := range svs {
require.NoError(t, rpcWriteSV(s, sv))
}
// Make the HTTP request
req, err = http.NewRequest("GET", "/v1/vars", nil)
require.NoError(t, err)
respW = httptest.NewRecorder()
// Make the request
obj, err = s.Server.SecureVariablesListRequest(respW, req)
require.NoError(t, err)
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
require.Equal(t, "true", respW.HeaderMap.Get("X-Nomad-KnownLeader"))
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-LastContact"))
// Check the output (the 3 we register )
require.Len(t, obj.([]*structs.SecureVariableMetadata), 4)
// test prefix query
req, err = http.NewRequest("GET", "/v1/vars?prefix="+svs[0].Path, nil)
require.NoError(t, err)
respW = httptest.NewRecorder()
// Make the request
obj, err = s.Server.SecureVariablesListRequest(respW, req)
require.NoError(t, err)
require.Len(t, obj.([]*structs.SecureVariableMetadata), 2)
})
rpcResetSV(s)
t.Run("error_badverb_query", func(t *testing.T) {
req, err := http.NewRequest("LOLWUT", "/v1/var/does/not/exist", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, ErrInvalidMethod)
require.Nil(t, obj)
})
t.Run("error_parse_query", func(t *testing.T) {
req, err := http.NewRequest("GET", "/v1/var/does/not/exist?wait=99a", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
_, _ = s.Server.SecureVariableSpecificRequest(respW, req)
require.Equal(t, http.StatusBadRequest, respW.Code)
require.Equal(t, "Invalid wait time", string(respW.Body.Bytes()))
})
t.Run("error_rpc_query", func(t *testing.T) {
req, err := http.NewRequest("GET", "/v1/var/does/not/exist?region=bad", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "No path to region")
require.Nil(t, obj)
})
t.Run("query_unset_path", func(t *testing.T) {
// Make a request for a non-existing variable
req, err := http.NewRequest("GET", "/v1/var/", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "missing secure variable path")
require.Nil(t, obj)
})
t.Run("query_unset_variable", func(t *testing.T) {
// Make a request for a non-existing variable
req, err := http.NewRequest("GET", "/v1/var/not/real", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "secure variable not found")
require.Nil(t, obj)
})
t.Run("query", func(t *testing.T) {
// Use RPC to make a test variable
sv1 := mock.SecureVariable()
require.NoError(t, rpcWriteSV(s, sv1))
// Query a variable
req, err := http.NewRequest("GET", "/v1/var/"+sv1.Path, nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
require.Equal(t, "true", respW.HeaderMap.Get("X-Nomad-KnownLeader"))
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-LastContact"))
// Check the output
require.Equal(t, sv1.Path, obj.(*structs.SecureVariableDecrypted).Path)
})
rpcResetSV(s)
sv1 := mock.SecureVariable()
t.Run("error_parse_create", func(t *testing.T) {
buf := encodeBrokenReq(&sv1)
req, err := http.NewRequest("PUT", "/v1/var/"+sv1.Path, buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "unexpected EOF")
require.Nil(t, obj)
})
t.Run("error_rpc_create", func(t *testing.T) {
buf := encodeReq(sv1)
req, err := http.NewRequest("PUT", "/v1/var/does/not/exist?region=bad", buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "No path to region")
require.Nil(t, obj)
})
t.Run("create_no_items", func(t *testing.T) {
sv2 := sv1.Copy()
sv2.Items = nil
buf := encodeReq(sv2)
req, err := http.NewRequest("PUT", "/v1/var/"+sv1.Path, buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "secure variable missing required Items object")
require.Nil(t, obj)
})
t.Run("create", func(t *testing.T) {
buf := encodeReq(sv1)
req, err := http.NewRequest("PUT", "/v1/var/"+sv1.Path, buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Nil(t, obj)
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
// Check the variable was put
out, err := rpcReadSV(s, sv1.Namespace, sv1.Path)
require.NoError(t, err)
require.NotNil(t, out)
// fixup times and indexes so the equality check is less gross
sv1.CreateIndex, sv1.ModifyIndex = out.CreateIndex, out.ModifyIndex
sv1.CreateTime, sv1.ModifyTime = out.CreateTime, out.ModifyTime
require.Equal(t, sv1.Path, out.Path)
require.Equal(t, sv1, out)
})
rpcResetSV(s)
t.Run("error_parse_update", func(t *testing.T) {
sv1U := sv1.Copy()
sv1U.Items["new"] = "new"
// break the request body
badBuf := encodeBrokenReq(&sv1U)
req, err := http.NewRequest("PUT", "/v1/var/"+sv1.Path, badBuf)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "unexpected EOF")
var cErr HTTPCodedError
require.ErrorAs(t, err, &cErr)
require.Equal(t, http.StatusBadRequest, cErr.Code())
require.Nil(t, obj)
})
t.Run("error_rpc_update", func(t *testing.T) {
sv1U := sv1.Copy()
sv1U.Items["new"] = "new"
// test broken rpc error
buf := encodeReq(&sv1U)
req, err := http.NewRequest("PUT", "/v1/var/"+sv1.Path+"?region=bad", buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "No path to region")
require.Nil(t, obj)
})
t.Run("update", func(t *testing.T) {
sv := mock.SecureVariable()
require.NoError(t, rpcWriteSV(s, sv))
sv, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
svU := sv.Copy()
svU.Items["new"] = "new"
// Make the HTTP request
buf := encodeReq(&svU)
req, err := http.NewRequest("PUT", "/v1/var/"+sv.Path, buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Nil(t, obj)
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
{
out, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
require.NotNil(t, out)
// Check that written varible does not equal the input to rule out input mutation
require.NotEqual(t, svU.SecureVariableMetadata, out.SecureVariableMetadata)
// Update the input token with the updated metadata so that we
// can use a simple equality check
svU.CreateIndex, svU.ModifyIndex = out.CreateIndex, out.ModifyIndex
svU.CreateTime, svU.ModifyTime = out.CreateTime, out.ModifyTime
require.Equal(t, svU.SecureVariableMetadata, out.SecureVariableMetadata)
// fmt writes sorted output of maps for testability.
require.Equal(t, fmt.Sprint(svU.Items), fmt.Sprint(out.Items))
}
})
t.Run("update-cas", func(t *testing.T) {
sv := mock.SecureVariable()
require.NoError(t, rpcWriteSV(s, sv))
sv, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
svU := sv.Copy()
svU.Items["new"] = "new"
// Make the HTTP request
{
buf := encodeReq(&svU)
req, err := http.NewRequest("PUT", "/v1/var/"+svU.Path+"?cas=0", buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, respW.Result().StatusCode)
// Evaluate the conflict variable
require.NotNil(t, obj)
conflict, ok := obj.(*structs.SecureVariableDecrypted)
require.True(t, ok, "Expected *structs.SecureVariableDecrypted, got %T", obj)
require.True(t, sv.Equals(*conflict))
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
}
// Check the variable was not updated
{
out, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
require.Equal(t, sv, out)
}
// Make the HTTP request
{
buf := encodeReq(&svU)
req, err := http.NewRequest("PUT", "/v1/var/"+svU.Path+"?cas="+fmt.Sprint(sv.ModifyIndex), buf)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Nil(t, obj)
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
}
// Check the variable was created correctly
{
out, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
require.NotNil(t, out)
require.NotEqual(t, sv, out)
require.NotEqual(t, svU.SecureVariableMetadata, out.SecureVariableMetadata)
// Update the input token with the updated metadata so that we
// can use a simple equality check
svU.CreateIndex, svU.ModifyIndex = out.CreateIndex, out.ModifyIndex
svU.CreateTime, svU.ModifyTime = out.CreateTime, out.ModifyTime
require.Equal(t, svU.SecureVariableMetadata, out.SecureVariableMetadata)
// fmt writes sorted output of maps for testability.
require.Equal(t, fmt.Sprint(svU.Items), fmt.Sprint(out.Items))
}
})
rpcResetSV(s)
t.Run("error_rpc_delete", func(t *testing.T) {
sv1 := mock.SecureVariable()
require.NoError(t, rpcWriteSV(s, sv1))
// Make the HTTP request
req, err := http.NewRequest("DELETE", "/v1/var/"+sv1.Path+"?region=bad", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.EqualError(t, err, "No path to region")
require.Nil(t, obj)
})
t.Run("delete-cas", func(t *testing.T) {
sv := mock.SecureVariable()
require.NoError(t, rpcWriteSV(s, sv))
sv, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
// Make the HTTP request
{
req, err := http.NewRequest("DELETE", "/v1/var/"+sv.Path+"?cas=0", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, respW.Result().StatusCode)
// Evaluate the conflict variable
require.NotNil(t, obj)
conflict, ok := obj.(*structs.SecureVariableDecrypted)
require.True(t, ok, "Expected *structs.SecureVariableDecrypted, got %T", obj)
require.True(t, sv.Equals(*conflict))
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
}
// Check variable was not deleted
{
svChk, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
require.NotNil(t, svChk)
require.Equal(t, sv, svChk)
}
// Make the HTTP request
{
req, err := http.NewRequest("DELETE", "/v1/var/"+sv.Path+"?cas="+fmt.Sprint(sv.ModifyIndex), nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Nil(t, obj)
}
// Check variable was deleted
{
svChk, err := rpcReadSV(s, sv.Namespace, sv.Path)
require.NoError(t, err)
require.Nil(t, svChk)
}
})
t.Run("delete", func(t *testing.T) {
sv1 := mock.SecureVariable()
require.NoError(t, rpcWriteSV(s, sv1))
// Make the HTTP request
req, err := http.NewRequest("DELETE", "/v1/var/"+sv1.Path, nil)
require.NoError(t, err)
respW := httptest.NewRecorder()
// Make the request
obj, err := s.Server.SecureVariableSpecificRequest(respW, req)
require.NoError(t, err)
require.Nil(t, obj)
// Check for the index
require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index"))
require.Equal(t, http.StatusNoContent, respW.Result().StatusCode)
// Check variable was deleted
sv, err := rpcReadSV(s, sv1.Namespace, sv1.Path)
require.NoError(t, err)
require.Nil(t, sv)
})
})
}
// encodeBrokenReq is a test helper that damages input JSON in order to create
// a parsing error for testing error pathways.
func encodeBrokenReq(obj interface{}) io.ReadCloser {
// var buf *bytes.Buffer
// enc := json.NewEncoder(buf)
// enc.Encode(obj)
b, _ := json.Marshal(obj)
b = b[0 : len(b)-5] // strip newline and final }
return ioutil.NopCloser(bytes.NewReader(b))
}
// rpcReadSV lets this test read a secure variable using the RPC endpoint
func rpcReadSV(s *TestAgent, ns, p string) (*structs.SecureVariableDecrypted, error) {
checkArgs := structs.SecureVariablesReadRequest{Path: p, QueryOptions: structs.QueryOptions{Namespace: ns, Region: "global"}}
var checkResp structs.SecureVariablesReadResponse
err := s.Agent.RPC(structs.SecureVariablesReadRPCMethod, &checkArgs, &checkResp)
return checkResp.Data, err
}
// rpcWriteSV lets this test write a secure variable using the RPC endpoint
func rpcWriteSV(s *TestAgent, sv *structs.SecureVariableDecrypted) error {
args := structs.SecureVariablesUpsertRequest{
Data: []*structs.SecureVariableDecrypted{sv},
WriteRequest: structs.WriteRequest{Namespace: sv.Namespace, Region: "global"},
}
var resp structs.SecureVariablesUpsertResponse
err := s.Agent.RPC(structs.SecureVariablesUpsertRPCMethod, &args, &resp)
if err != nil {
return err
}
nv, err := rpcReadSV(s, sv.Namespace, sv.Path)
if err != nil {
return err
}
sv.CreateIndex = nv.CreateIndex
sv.CreateTime = nv.CreateTime
sv.ModifyIndex = nv.ModifyIndex
sv.ModifyTime = nv.ModifyTime
return nil
}
// rpcResetSV lists all the secure variables for every namespace in the global
// region and deletes them using the RPC endpoints
func rpcResetSV(s *TestAgent) {
var lArgs structs.SecureVariablesListRequest
var lResp structs.SecureVariablesListResponse
lArgs = structs.SecureVariablesListRequest{
QueryOptions: structs.QueryOptions{
Namespace: "*",
Region: "global",
},
}
err := s.Agent.RPC(structs.SecureVariablesListRPCMethod, &lArgs, &lResp)
require.NoError(s.T, err)
var dArgs structs.SecureVariablesDeleteRequest
var dResp structs.SecureVariablesDeleteResponse
for _, v := range lResp.Data {
dArgs = structs.SecureVariablesDeleteRequest{
Path: v.Path,
WriteRequest: structs.WriteRequest{
Namespace: v.Namespace,
Region: "global",
},
}
err := s.Agent.RPC(structs.SecureVariablesDeleteRPCMethod, &dArgs, &dResp)
require.NoError(s.T, err)
}
err = s.Agent.RPC(structs.SecureVariablesListRPCMethod, &lArgs, &lResp)
require.NoError(s.T, err)
require.Equal(s.T, 0, len(lResp.Data))
}

View File

@@ -134,6 +134,12 @@ server {
enable_event_broker = false
event_buffer_size = 200
plan_rejection_tracker {
enabled = true
node_threshold = 100
node_window = "41m"
}
server_join {
retry_join = ["1.1.1.1", "2.2.2.2"]
retry_max = 3
@@ -229,6 +235,7 @@ consul {
client_auto_join = true
auto_advertise = true
checks_use_advertise = true
timeout = "5s"
}
vault {

View File

@@ -161,6 +161,7 @@
"server_serf_check_name": "nomad-server-serf-health-check",
"server_service_name": "nomad",
"ssl": true,
"timeout": "5s",
"token": "token1",
"verify_ssl": true
}
@@ -280,6 +281,11 @@
"node_gc_threshold": "12h",
"non_voting_server": true,
"num_schedulers": 2,
"plan_rejection_tracker": {
"enabled": true,
"node_threshold": 100,
"node_window": "41m"
},
"raft_protocol": 3,
"raft_multiplier": 4,
"redundancy_zone": "foo",

View File

@@ -55,6 +55,10 @@
"bootstrap_expect": 3,
"enabled": true,
"encrypt": "sHck3WL6cxuhuY7Mso9BHA==",
"plan_rejection_tracker": {
"node_threshold": 100,
"node_window": "31m"
},
"retry_join": [
"10.0.0.101",
"10.0.0.102",

View File

@@ -20,6 +20,10 @@
"bootstrap_expect": 3,
"enabled": true,
"encrypt": "sHck3WL6cxuhuY7Mso9BHA==",
"plan_rejection_tracker": {
"node_threshold": 100,
"node_window": "31m"
},
"retry_join": [
"10.0.0.101",
"10.0.0.102",

View File

@@ -44,7 +44,7 @@ func (c *DeprecatedCommand) Run(args []string) int {
func (c *DeprecatedCommand) warn() {
c.Ui.Warn(wrapAtLength(fmt.Sprintf(
"WARNING! The \"nomad %s\" command is deprecated. Please use \"nomad %s\" "+
"instead. This command will be removed in Nomad 0.10 (or later).",
"instead. This command will be removed a later version of Nomad.",
c.Old,
c.New)))
c.Ui.Warn("")
@@ -305,16 +305,6 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"keygen": func() (cli.Command, error) {
return &OperatorKeygenCommand{
Meta: meta,
}, nil
},
"keyring": func() (cli.Command, error) {
return &OperatorKeyringCommand{
Meta: meta,
}, nil
},
"job": func() (cli.Command, error) {
return &JobCommand{
Meta: meta,
@@ -529,16 +519,49 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"operator keygen": func() (cli.Command, error) {
return &OperatorKeygenCommand{
Meta: meta,
}, nil
},
// COMPAT(1.4.0): deprecated, remove in Nomad 1.5.0
// Note: we can't just put this in the DeprecatedCommand list
// because the flags have changed too. So we've provided the
// deprecation warning in the original command and when it's
// time to remove it we can remove the entire command
"operator keyring": func() (cli.Command, error) {
return &OperatorKeyringCommand{
Meta: meta,
}, nil
},
"operator gossip keyring": func() (cli.Command, error) {
return &OperatorGossipKeyringCommand{
Meta: meta,
}, nil
},
"operator gossip keyring install": func() (cli.Command, error) {
return &OperatorGossipKeyringInstallCommand{
Meta: meta,
}, nil
},
"operator gossip keyring use": func() (cli.Command, error) {
return &OperatorGossipKeyringUseCommand{
Meta: meta,
}, nil
},
"operator gossip keyring list": func() (cli.Command, error) {
return &OperatorGossipKeyringListCommand{
Meta: meta,
}, nil
},
"operator gossip keyring remove": func() (cli.Command, error) {
return &OperatorGossipKeyringRemoveCommand{
Meta: meta,
}, nil
},
"operator gossip keyring generate": func() (cli.Command, error) {
return &OperatorGossipKeyringGenerateCommand{
Meta: meta,
}, nil
},
"operator metrics": func() (cli.Command, error) {
return &OperatorMetricsCommand{
Meta: meta,
@@ -591,6 +614,31 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"operator secure-variables keyring": func() (cli.Command, error) {
return &OperatorSecureVariablesKeyringCommand{
Meta: meta,
}, nil
},
"operator secure-variables keyring install": func() (cli.Command, error) {
return &OperatorSecureVariablesKeyringInstallCommand{
Meta: meta,
}, nil
},
"operator secure-variables keyring list": func() (cli.Command, error) {
return &OperatorSecureVariablesKeyringListCommand{
Meta: meta,
}, nil
},
"operator secure-variables keyring remove": func() (cli.Command, error) {
return &OperatorSecureVariablesKeyringRemoveCommand{
Meta: meta,
}, nil
},
"operator secure-variables keyring rotate": func() (cli.Command, error) {
return &OperatorSecureVariablesKeyringRotateCommand{
Meta: meta,
}, nil
},
"operator snapshot": func() (cli.Command, error) {
return &OperatorSnapshotCommand{
Meta: meta,
@@ -853,6 +901,16 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"var": func() (cli.Command, error) {
return &VarCommand{
Meta: meta,
}, nil
},
"var list": func() (cli.Command, error) {
return &VarListCommand{
Meta: meta,
}, nil
},
"version": func() (cli.Command, error) {
return &VersionCommand{
Version: version.GetVersion(),
@@ -936,9 +994,20 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
"keygen": func() (cli.Command, error) {
return &DeprecatedCommand{
Old: "keygen",
New: "operator keygen",
New: "operator gossip keyring generate",
Meta: meta,
Command: &OperatorKeygenCommand{
Command: &OperatorGossipKeyringGenerateCommand{
Meta: meta,
},
}, nil
},
"operator keygen": func() (cli.Command, error) {
return &DeprecatedCommand{
Old: "operator keygen",
New: "operator gossip keyring generate",
Meta: meta,
Command: &OperatorGossipKeyringGenerateCommand{
Meta: meta,
},
}, nil
@@ -947,7 +1016,7 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
"keyring": func() (cli.Command, error) {
return &DeprecatedCommand{
Old: "keyring",
New: "operator keyring",
New: "operator gossip keyring",
Meta: meta,
Command: &OperatorKeyringCommand{
Meta: meta,

View File

@@ -87,7 +87,7 @@ func commandAssetsConnectShortNomad() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "command/assets/connect-short.nomad", size: 1071, mode: os.FileMode(420), modTime: time.Unix(1654712611, 0)}
info := bindataFileInfo{name: "command/assets/connect-short.nomad", size: 1071, mode: os.FileMode(436), modTime: time.Unix(1656778128, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@@ -107,7 +107,7 @@ func commandAssetsConnectNomad() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "command/assets/connect.nomad", size: 18126, mode: os.FileMode(420), modTime: time.Unix(1654712611, 0)}
info := bindataFileInfo{name: "command/assets/connect.nomad", size: 18126, mode: os.FileMode(436), modTime: time.Unix(1656778128, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@@ -127,7 +127,7 @@ func commandAssetsExampleShortNomad() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "command/assets/example-short.nomad", size: 369, mode: os.FileMode(420), modTime: time.Unix(1654554902, 0)}
info := bindataFileInfo{name: "command/assets/example-short.nomad", size: 369, mode: os.FileMode(436), modTime: time.Unix(1656778128, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
@@ -147,7 +147,7 @@ func commandAssetsExampleNomad() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "command/assets/example.nomad", size: 16351, mode: os.FileMode(420), modTime: time.Unix(1654554902, 0)}
info := bindataFileInfo{name: "command/assets/example.nomad", size: 16351, mode: os.FileMode(436), modTime: time.Unix(1656778128, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}

View File

@@ -0,0 +1,72 @@
package command
import (
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// OperatorGossipKeyringCommand is a Command implementation that
// handles querying, installing, and removing gossip encryption keys
// from a keyring.
type OperatorGossipKeyringCommand struct {
Meta
}
func (c *OperatorGossipKeyringCommand) Help() string {
helpText := `
Usage: nomad operator gossip keyring [options]
Manages encryption keys used for gossip messages between Nomad servers. Gossip
encryption is optional. When enabled, this command may be used to examine
active encryption keys in the cluster, add new keys, and remove old ones. When
combined, this functionality provides the ability to perform key rotation
cluster-wide, without disrupting the cluster.
Generate an encryption key:
$ nomad operator gossip keyring generate
List all gossip encryption keys:
$ nomad operator gossip keyring list
Remove an encryption key from the keyring:
$ nomad operator gossip keyring remove <key>
Install an encryption key from backup:
$ nomad operator gossip keyring install <key>
Use an already-installed encryption key:
$ nomad operator gossip keyring use <key>
Please see individual subcommand help for detailed usage information.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorGossipKeyringCommand) Synopsis() string {
return "Manages gossip layer encryption keys"
}
func (c *OperatorGossipKeyringCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorGossipKeyringCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *OperatorGossipKeyringCommand) Name() string { return "operator gossip keyring" }
func (c *OperatorGossipKeyringCommand) Run(args []string) int {
return cli.RunResultHelp
}

View File

@@ -7,19 +7,20 @@ import (
"strings"
)
// OperatorKeygenCommand is a Command implementation that generates an encryption
// key for use in `nomad agent`.
type OperatorKeygenCommand struct {
// OperatorGossipKeyringGenerateCommand is a Command implementation that
// generates an encryption key for use in `nomad agent`.
type OperatorGossipKeyringGenerateCommand struct {
Meta
}
func (c *OperatorKeygenCommand) Synopsis() string {
func (c *OperatorGossipKeyringGenerateCommand) Synopsis() string {
return "Generates a new encryption key"
}
func (c *OperatorKeygenCommand) Help() string {
func (c *OperatorGossipKeyringGenerateCommand) Help() string {
helpText := `
Usage: nomad operator keygen
Usage: nomad operator gossip keying generate
Alias: nomad operator keygen
Generates a new 32-byte encryption key that can be used to configure the
agent to encrypt traffic. The output of this command is already
@@ -28,9 +29,11 @@ Usage: nomad operator keygen
return strings.TrimSpace(helpText)
}
func (c *OperatorKeygenCommand) Name() string { return "operator keygen" }
func (c *OperatorGossipKeyringGenerateCommand) Name() string {
return "operator gossip keyring generate"
}
func (c *OperatorKeygenCommand) Run(_ []string) int {
func (c *OperatorGossipKeyringGenerateCommand) Run(_ []string) int {
key := make([]byte, 32)
n, err := rand.Reader.Read(key)
if err != nil {

View File

@@ -0,0 +1,87 @@
package command
import (
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// OperatorGossipKeyringInstallCommand is a Command implementation
// that handles installing a gossip encryption key from a keyring
type OperatorGossipKeyringInstallCommand struct {
Meta
}
func (c *OperatorGossipKeyringInstallCommand) Help() string {
helpText := `
Usage: nomad operator gossip keyring install [options] <key>
Install a new encryption key used for gossip. This will broadcast the new key
to all members in the cluster.
This command can only be run against server nodes. It returns 0 if all nodes
reply and there are no errors. If any node fails to reply or reports failure,
the exit code will be 1.
If ACLs are enabled, this command requires a token with the 'agent:write'
capability.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorGossipKeyringInstallCommand) Synopsis() string {
return "Install a gossip encryption key"
}
func (c *OperatorGossipKeyringInstallCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorGossipKeyringInstallCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictAnything
}
func (c *OperatorGossipKeyringInstallCommand) Name() string { return "operator gossip keyring install" }
func (c *OperatorGossipKeyringInstallCommand) Run(args []string) int {
flags := c.Meta.FlagSet("operator-gossip-keyring-install", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
c.Ui = &cli.PrefixedUi{
OutputPrefix: "",
InfoPrefix: "==> ",
ErrorPrefix: "",
Ui: c.Ui,
}
args = flags.Args()
if len(args) != 1 {
c.Ui.Error("This command requires one argument: <key>")
c.Ui.Error(commandErrorText(c))
return 1
}
installKey := args[0]
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
c.Ui.Output("Installing new gossip encryption key...")
_, err = client.Agent().InstallKey(installKey)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
return 0
}

View File

@@ -0,0 +1,98 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// OperatorGossipKeyringListCommand is a Command implementation
// that handles removing a gossip encryption key from a keyring
type OperatorGossipKeyringListCommand struct {
Meta
}
func (c *OperatorGossipKeyringListCommand) Help() string {
helpText := `
Usage: nomad operator gossip keyring list [options]
List all gossip keys currently in use within the cluster.
This command can only be run against server nodes. It returns 0 if all nodes
reply and there are no errors. If any node fails to reply or reports failure,
the exit code will be 1.
If ACLs are enabled, this command requires a token with the 'agent:write'
capability.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorGossipKeyringListCommand) Synopsis() string {
return "List gossip encryption keys"
}
func (c *OperatorGossipKeyringListCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorGossipKeyringListCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictAnything
}
func (c *OperatorGossipKeyringListCommand) Name() string { return "operator gossip keyring list" }
func (c *OperatorGossipKeyringListCommand) Run(args []string) int {
flags := c.Meta.FlagSet("operator-gossip-keyring-list", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
c.Ui = &cli.PrefixedUi{
OutputPrefix: "",
InfoPrefix: "==> ",
ErrorPrefix: "",
Ui: c.Ui,
}
args = flags.Args()
if len(args) != 0 {
c.Ui.Error("This command requires no arguments")
c.Ui.Error(commandErrorText(c))
return 1
}
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
c.Ui.Output("Gathering installed encryption keys...")
r, err := client.Agent().ListKeys()
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
c.handleKeyResponse(r)
return 0
}
func (c *OperatorGossipKeyringListCommand) handleKeyResponse(resp *api.KeyringResponse) {
out := make([]string, len(resp.Keys)+1)
out[0] = "Key"
i := 1
for k := range resp.Keys {
out[i] = k
i = i + 1
}
c.Ui.Output(formatList(out))
}

View File

@@ -0,0 +1,87 @@
package command
import (
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// OperatorGossipKeyringRemoveCommand is a Command implementation
// that handles removing a gossip encryption key from a keyring
type OperatorGossipKeyringRemoveCommand struct {
Meta
}
func (c *OperatorGossipKeyringRemoveCommand) Help() string {
helpText := `
Usage: nomad operator gossip keyring remove [options] <key>
Remove the given key from the cluster. This operation may only be performed
on keys which are not currently the primary key.
This command can only be run against server nodes. It returns 0 if all nodes
reply and there are no errors. If any node fails to reply or reports failure,
the exit code will be 1.
If ACLs are enabled, this command requires a token with the 'agent:write'
capability.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorGossipKeyringRemoveCommand) Synopsis() string {
return "Remove a gossip encryption key"
}
func (c *OperatorGossipKeyringRemoveCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorGossipKeyringRemoveCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictAnything
}
func (c *OperatorGossipKeyringRemoveCommand) Name() string { return "operator gossip keyring remove" }
func (c *OperatorGossipKeyringRemoveCommand) Run(args []string) int {
flags := c.Meta.FlagSet("operator-gossip-keyring-remove", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
c.Ui = &cli.PrefixedUi{
OutputPrefix: "",
InfoPrefix: "==> ",
ErrorPrefix: "",
Ui: c.Ui,
}
args = flags.Args()
if len(args) != 1 {
c.Ui.Error("This command requires one argument: <key>")
c.Ui.Error(commandErrorText(c))
return 1
}
removeKey := args[0]
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
c.Ui.Output("Removing gossip encryption key...")
_, err = client.Agent().RemoveKey(removeKey)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
return 0
}

View File

@@ -8,11 +8,11 @@ import (
"github.com/mitchellh/cli"
)
func TestKeygenCommand(t *testing.T) {
func TestGossipKeyringGenerateCommand(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
c := &OperatorKeygenCommand{Meta: Meta{Ui: ui}}
c := &OperatorGossipKeyringGenerateCommand{Meta: Meta{Ui: ui}}
code := c.Run(nil)
if code != 0 {
t.Fatalf("bad: %d", code)

View File

@@ -0,0 +1,87 @@
package command
import (
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// OperatorGossipKeyringUseCommand is a Command implementation that
// handles setting the gossip encryption key from a keyring
type OperatorGossipKeyringUseCommand struct {
Meta
}
func (c *OperatorGossipKeyringUseCommand) Help() string {
helpText := `
Usage: nomad operator gossip keyring use [options] <key>
Change the encryption key used for gossip. The key must already be installed
before this operator can succeed.
This command can only be run against server nodes. It returns 0 if all nodes
reply and there are no errors. If any node fails to reply or reports failure,
the exit code will be 1.
If ACLs are enabled, this command requires a token with the 'agent:write'
capability.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorGossipKeyringUseCommand) Synopsis() string {
return "Change the gossip encryption key"
}
func (c *OperatorGossipKeyringUseCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorGossipKeyringUseCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictAnything
}
func (c *OperatorGossipKeyringUseCommand) Name() string { return "operator gossip keyring use" }
func (c *OperatorGossipKeyringUseCommand) Run(args []string) int {
flags := c.Meta.FlagSet("operator-gossip-keyring-use", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
c.Ui = &cli.PrefixedUi{
OutputPrefix: "",
InfoPrefix: "==> ",
ErrorPrefix: "",
Ui: c.Ui,
}
args = flags.Args()
if len(args) != 1 {
c.Ui.Error("This command requires one argument: <key>")
c.Ui.Error(commandErrorText(c))
return 1
}
useKey := args[0]
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
c.Ui.Output("Changing primary gossip encryption key...")
_, err = client.Agent().UseKey(useKey)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
return 0
}

View File

@@ -88,6 +88,10 @@ func (c *OperatorKeyringCommand) Run(args []string) int {
return 1
}
c.Ui.Warn(wrapAtLength("WARNING! The \"nomad operator keyring\" command " +
"is deprecated. Please use \"nomad operator gossip keyring\" instead. " +
"This command will be removed in Nomad 1.5.0."))
c.Ui.Warn("")
c.Ui = &cli.PrefixedUi{
OutputPrefix: "",
InfoPrefix: "==> ",

View File

@@ -0,0 +1,87 @@
package command
import (
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/hashicorp/nomad/api"
)
// OperatorSecureVariablesKeyringCommand is a Command implementation
// that handles querying, installing, and removing secure variables
// encryption keys from a keyring.
type OperatorSecureVariablesKeyringCommand struct {
Meta
}
func (c *OperatorSecureVariablesKeyringCommand) Help() string {
helpText := `
Usage: nomad operator secure-variables keyring [options]
Manages encryption keys used for storing secure variables. This command may be
used to examine active encryption keys in the cluster, rotate keys, add new
keys from backups, or remove unused keys.
If ACLs are enabled, all subcommands requires a management token.
Rotate the encryption key:
$ nomad operator secure-variables keyring rotate
List all encryption key metadata:
$ nomad operator secure-variables keyring list
Remove an encryption key from the keyring:
$ nomad operator secure-variables keyring remove <key ID>
Install an encryption key from backup:
$ nomad operator secure-variables keyring install <path to .json file>
Please see individual subcommand help for detailed usage information.
`
return strings.TrimSpace(helpText)
}
func (c *OperatorSecureVariablesKeyringCommand) Synopsis() string {
return "Manages secure variables encryption keys"
}
func (c *OperatorSecureVariablesKeyringCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorSecureVariablesKeyringCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *OperatorSecureVariablesKeyringCommand) Name() string {
return "secure-variables keyring"
}
func (c *OperatorSecureVariablesKeyringCommand) Run(args []string) int {
return cli.RunResultHelp
}
// renderSecureVariablesKeysResponse is a helper for formatting the
// keyring API responses
func renderSecureVariablesKeysResponse(keys []*api.RootKeyMeta, verbose bool) string {
length := fullId
if !verbose {
length = 8
}
out := make([]string, len(keys)+1)
out[0] = "Key|State|Create Time"
i := 1
for _, k := range keys {
out[i] = fmt.Sprintf("%s|%v|%s",
k.KeyID[:length], k.State, formatUnixNanoTime(k.CreateTime))
i = i + 1
}
return formatList(out)
}

View File

@@ -0,0 +1,114 @@
package command
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
// OperatorSecureVariablesKeyringInstallCommand is a Command
// implementation that handles installing secure variables encryption
// keys from a keyring.
type OperatorSecureVariablesKeyringInstallCommand struct {
Meta
}
func (c *OperatorSecureVariablesKeyringInstallCommand) Help() string {
helpText := `
Usage: nomad operator secure-variables keyring install [options] <filepath>
Install a new encryption key used for storing secure variables and workload
identity signing. The key file must be a JSON file previously written by Nomad
to the keystore. The key file will be read from stdin by specifying "-",
otherwise a path to the file is expected.
If ACLs are enabled, this command requires a management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorSecureVariablesKeyringInstallCommand) Synopsis() string {
return "Installs a secure variables encryption key"
}
func (c *OperatorSecureVariablesKeyringInstallCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-verbose": complete.PredictNothing,
})
}
func (c *OperatorSecureVariablesKeyringInstallCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFiles("*.json")
}
func (c *OperatorSecureVariablesKeyringInstallCommand) Name() string {
return "secure-variables keyring install"
}
func (c *OperatorSecureVariablesKeyringInstallCommand) Run(args []string) int {
flags := c.Meta.FlagSet("secure-variables keyring install", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
args = flags.Args()
if len(args) != 1 {
c.Ui.Error("This command requires one argument: <filepath>")
c.Ui.Error(commandErrorText(c))
return 1
}
installKey := args[0]
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
if fi, err := os.Stat(installKey); (installKey == "-" || err == nil) && !fi.IsDir() {
var buf []byte
if installKey == "-" {
buf, err = ioutil.ReadAll(os.Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read stdin: %v", err))
return 1
}
} else {
buf, err = ioutil.ReadFile(installKey)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read file: %v", err))
return 1
}
}
key := &api.RootKey{}
dec := json.NewDecoder(bytes.NewBuffer(buf))
if err := dec.Decode(key); err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse key file: %v", err))
return 1
}
_, err := client.Keyring().Update(key, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
c.Ui.Output(fmt.Sprintf("Installed encryption key %s", key.Meta.KeyID))
return 0
}
// Should never make it here
return 0
}

View File

@@ -0,0 +1,88 @@
package command
import (
"fmt"
"strings"
"github.com/posener/complete"
)
// OperatorSecureVariablesKeyringListCommand is a Command
// implementation that lists the secure variables encryption keys.
type OperatorSecureVariablesKeyringListCommand struct {
Meta
}
func (c *OperatorSecureVariablesKeyringListCommand) Help() string {
helpText := `
Usage: nomad operator secure-variables keyring list [options]
List the currently installed keys. This list returns key metadata and not
sensitive key material.
If ACLs are enabled, this command requires a management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
Keyring Options:
-verbose
Show full information.
`
return strings.TrimSpace(helpText)
}
func (c *OperatorSecureVariablesKeyringListCommand) Synopsis() string {
return "Lists the secure variables encryption keys"
}
func (c *OperatorSecureVariablesKeyringListCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-verbose": complete.PredictNothing,
})
}
func (c *OperatorSecureVariablesKeyringListCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *OperatorSecureVariablesKeyringListCommand) Name() string {
return "secure-variables keyring list"
}
func (c *OperatorSecureVariablesKeyringListCommand) Run(args []string) int {
var verbose bool
flags := c.Meta.FlagSet("secure-variables keyring list", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&verbose, "verbose", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
args = flags.Args()
if len(args) != 0 {
c.Ui.Error("This command requires no arguments.")
c.Ui.Error(commandErrorText(c))
return 1
}
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
resp, _, err := client.Keyring().List(nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
c.Ui.Output(renderSecureVariablesKeysResponse(resp, verbose))
return 0
}

View File

@@ -0,0 +1,83 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
// OperatorSecureVariablesKeyringRemoveCommand is a Command
// implementation that handles removeing secure variables encryption
// keys from a keyring.
type OperatorSecureVariablesKeyringRemoveCommand struct {
Meta
}
func (c *OperatorSecureVariablesKeyringRemoveCommand) Help() string {
helpText := `
Usage: nomad operator secure-variables keyring remove [options] <key ID>
Remove an encryption key from the cluster. This operation may only be
performed on keys that are not the active key.
If ACLs are enabled, this command requires a management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (c *OperatorSecureVariablesKeyringRemoveCommand) Synopsis() string {
return "Removes a secure variables encryption key"
}
func (c *OperatorSecureVariablesKeyringRemoveCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *OperatorSecureVariablesKeyringRemoveCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictAnything
}
func (c *OperatorSecureVariablesKeyringRemoveCommand) Name() string {
return "secure-variables keyring remove"
}
func (c *OperatorSecureVariablesKeyringRemoveCommand) Run(args []string) int {
var verbose bool
flags := c.Meta.FlagSet("secure-variables keyring remove", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&verbose, "verbose", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
args = flags.Args()
if len(args) != 1 {
c.Ui.Error("This command requires one argument: <key ID>")
c.Ui.Error(commandErrorText(c))
return 1
}
removeKey := args[0]
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
_, err = client.Keyring().Delete(&api.KeyringDeleteOptions{
KeyID: removeKey,
}, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
c.Ui.Output(fmt.Sprintf("Removed encryption key %s", removeKey))
return 0
}

View File

@@ -0,0 +1,96 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
// OperatorSecureVariablesKeyringRotateCommand is a Command
// implementation that rotates the secure variables encryption key.
type OperatorSecureVariablesKeyringRotateCommand struct {
Meta
}
func (c *OperatorSecureVariablesKeyringRotateCommand) Help() string {
helpText := `
Usage: nomad operator secure-variables keyring rotate [options]
Generate a new encryption key for all future variables.
If ACLs are enabled, this command requires a management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
Keyring Options:
-full
Decrypt all existing variables and re-encrypt with the new key. This command
will immediately return and the re-encryption process will run
asynchronously on the leader.
-verbose
Show full information.
`
return strings.TrimSpace(helpText)
}
func (c *OperatorSecureVariablesKeyringRotateCommand) Synopsis() string {
return "Rotates the secure variables encryption key"
}
func (c *OperatorSecureVariablesKeyringRotateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-full": complete.PredictNothing,
"-verbose": complete.PredictNothing,
})
}
func (c *OperatorSecureVariablesKeyringRotateCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *OperatorSecureVariablesKeyringRotateCommand) Name() string {
return "secure-variables keyring rotate"
}
func (c *OperatorSecureVariablesKeyringRotateCommand) Run(args []string) int {
var rotateFull, verbose bool
flags := c.Meta.FlagSet("secure-variables keyring rotate", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&rotateFull, "full", false, "full key rotation")
flags.BoolVar(&verbose, "verbose", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
args = flags.Args()
if len(args) != 0 {
c.Ui.Error("This command requires no arguments.")
c.Ui.Error(commandErrorText(c))
return 1
}
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating nomad cli client: %s", err))
return 1
}
resp, _, err := client.Keyring().Rotate(
&api.KeyringRotateOptions{Full: rotateFull}, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("error: %s", err))
return 1
}
c.Ui.Output(renderSecureVariablesKeysResponse([]*api.RootKeyMeta{resp}, verbose))
return 0
}

View File

@@ -6,7 +6,9 @@ import (
"os"
"strings"
flaghelper "github.com/hashicorp/nomad/helper/flags"
"github.com/hashicorp/nomad/helper/raftutil"
"github.com/hashicorp/nomad/nomad"
"github.com/posener/complete"
)
@@ -16,13 +18,19 @@ type OperatorSnapshotStateCommand struct {
func (c *OperatorSnapshotStateCommand) Help() string {
helpText := `
Usage: nomad operator snapshot state <file>
Usage: nomad operator snapshot state [options] <file>
Displays a JSON representation of state in the snapshot.
To inspect the file "backup.snap":
$ nomad operator snapshot state backup.snap
Snapshot State Options:
-filter
Specifies an expression used to filter query results.
`
return strings.TrimSpace(helpText)
}
@@ -42,14 +50,31 @@ func (c *OperatorSnapshotStateCommand) Synopsis() string {
func (c *OperatorSnapshotStateCommand) Name() string { return "operator snapshot state" }
func (c *OperatorSnapshotStateCommand) Run(args []string) int {
var filterExpr flaghelper.StringFlag
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.Var(&filterExpr, "filter", "")
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse args: %v", err))
return 1
}
filter, err := nomad.NewFSMFilter(filterExpr.String())
if err != nil {
c.Ui.Error(fmt.Sprintf("Invalid filter expression %q: %s", filterExpr, err))
return 1
}
// Check that we either got no filename or exactly one.
if len(args) != 1 {
if len(flags.Args()) != 1 {
c.Ui.Error("This command takes one argument: <file>")
c.Ui.Error(commandErrorText(c))
return 1
}
path := args[0]
path := flags.Args()[0]
f, err := os.Open(path)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error opening snapshot file: %s", err))
@@ -57,7 +82,7 @@ func (c *OperatorSnapshotStateCommand) Run(args []string) int {
}
defer f.Close()
state, meta, err := raftutil.RestoreFromArchive(f)
state, meta, err := raftutil.RestoreFromArchive(f, filter)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read archive file: %s", err))
return 1

74
command/var.go Normal file
View File

@@ -0,0 +1,74 @@
package command
import (
"strings"
"github.com/hashicorp/nomad/api/contexts"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
type VarCommand struct {
Meta
}
func (f *VarCommand) Help() string {
helpText := `
Usage: nomad var <subcommand> [options] [args]
This command groups subcommands for interacting with secure variables. Secure
variables allow operators to provide credentials and otherwise sensitive
material to Nomad jobs at runtime via the template stanza or directly through
the Nomad API and CLI.
Users can create new secure variables; list, inspect, and delete existing
secure variables, and more. For a full guide on secure variables see:
https://www.nomadproject.io/guides/vars.html
Create a secure variable specification file:
$ nomad var init
Upsert a secure variable:
$ nomad var put <path>
Examine a secure variable:
$ nomad var get <path>
List existing secure variables:
$ nomad var list <prefix>
Please see the individual subcommand help for detailed usage information.
`
return strings.TrimSpace(helpText)
}
func (f *VarCommand) Synopsis() string {
return "Interact with secure variables"
}
func (f *VarCommand) Name() string { return "var" }
func (f *VarCommand) Run(args []string) int {
return cli.RunResultHelp
}
// SecureVariablePathPredictor returns a var predictor
func SecureVariablePathPredictor(factory ApiClientFactory) complete.Predictor {
return complete.PredictFunc(func(a complete.Args) []string {
client, err := factory()
if err != nil {
return nil
}
resp, _, err := client.Search().PrefixSearch(a.Last, contexts.SecureVariables, nil)
if err != nil {
return []string{}
}
return resp.Matches[contexts.SecureVariables]
})
}

276
command/var_list.go Normal file
View File

@@ -0,0 +1,276 @@
package command
import (
"fmt"
"sort"
"strings"
"time"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
const (
msgSecureVariableNotFound = "No matching secure variables found"
msgWarnFilterPerformance = "Filter queries require a full scan of the data; use prefix searching where possible"
)
type VarListCommand struct {
Prefix string
Meta
}
func (c *VarListCommand) Help() string {
helpText := `
Usage: nomad var list [options] <prefix>
List is used to list available secure variables. Supplying an optional prefix,
filters the list to variables having a path starting with the prefix.
If ACLs are enabled, this command will return only secure variables stored at
namespaced paths where the token has the ` + "`read`" + ` capability.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
List Options:
-per-page
How many results to show per page.
-page-token
Where to start pagination.
-filter
Specifies an expression used to filter query results. Queries using this
option are less efficient than using the prefix parameter; therefore,
the prefix parameter should be used whenever possible.
-json
Output the secure variables in JSON format.
-t
Format and display the secure variables using a Go template.
-q
Output matching secure variable paths with no additional information.
This option overrides the ` + "`-t`" + ` option.
`
return strings.TrimSpace(helpText)
}
func (c *VarListCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
},
)
}
func (c *VarListCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *VarListCommand) Synopsis() string {
return "List secure variable metadata"
}
func (c *VarListCommand) Name() string { return "var list" }
func (c *VarListCommand) Run(args []string) int {
var json, quiet bool
var perPage int
var tmpl, pageToken, filter, prefix string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&quiet, "q", false, "")
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.IntVar(&perPage, "per-page", 0, "")
flags.StringVar(&pageToken, "page-token", "", "")
flags.StringVar(&filter, "filter", "", "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we got no arguments
args = flags.Args()
if l := len(args); l > 1 {
c.Ui.Error("This command takes flags and either no arguments or one: <prefix>")
c.Ui.Error(commandErrorText(c))
return 1
}
if len(args) == 1 {
prefix = args[0]
}
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
if filter != "" {
c.Ui.Warn(msgWarnFilterPerformance)
}
qo := &api.QueryOptions{
Filter: filter,
PerPage: int32(perPage),
NextToken: pageToken,
Params: map[string]string{},
}
vars, qm, err := client.SecureVariables().PrefixList(prefix, qo)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error retrieving vars: %s", err))
return 1
}
switch {
case json:
// obj and items enable us to rework the output before sending it
// to the Format method for transformation into JSON.
var obj, items interface{}
obj = vars
items = vars
if quiet {
items = dataToQuietJSONReadySlice(vars, c.Meta.namespace)
obj = items
}
// If the response is paginated, we need to provide a means for the
// caller to get to the pagination information. Wrapping the list
// in a struct for the special case allows this extra data without
// adding unnecessary structure in the non-paginated case.
if perPage > 0 {
obj = struct {
Data interface{}
QueryMeta *api.QueryMeta
}{
items,
qm,
}
}
// By this point, the output is ready to be transformed to JSON via
// the Format func.
out, err := Format(json, tmpl, obj)
if err != nil {
c.Ui.Error(err.Error())
return 1
}
c.Ui.Output(out)
// Since the JSON formatting deals with the pagination information
// itself, exit the command here so that it doesn't double print.
return 0
case quiet:
c.Ui.Output(
formatList(
dataToQuietStringSlice(vars, c.Meta.namespace)))
case len(tmpl) > 0:
out, err := Format(json, tmpl, vars)
if err != nil {
c.Ui.Error(err.Error())
return 1
}
c.Ui.Output(out)
default:
c.Ui.Output(formatVarStubs(vars))
}
if qm.NextToken != "" {
// This uses Ui.Warn to output the next page token to stderr
// so that scripts consuming paths from stdout will not have
// to special case the output.
c.Ui.Warn(fmt.Sprintf("Next page token: %s", qm.NextToken))
}
return 0
}
func formatVarStubs(vars []*api.SecureVariableMetadata) string {
if len(vars) == 0 {
return msgSecureVariableNotFound
}
// Sort the output by variable namespace, path
sort.Slice(vars, func(i, j int) bool {
if vars[i].Namespace == vars[j].Namespace {
return vars[i].Path < vars[j].Path
}
return vars[i].Namespace < vars[j].Namespace
})
rows := make([]string, len(vars)+1)
rows[0] = "Namespace|Path|Last Updated"
for i, sv := range vars {
rows[i+1] = fmt.Sprintf("%s|%s|%s",
sv.Namespace,
sv.Path,
time.Unix(0, sv.ModifyTime),
)
}
return formatList(rows)
}
func dataToQuietStringSlice(vars []*api.SecureVariableMetadata, ns string) []string {
// If ns is the wildcard namespace, we have to provide namespace
// as part of the quiet output, otherwise it can be a simple list
// of paths.
toPathStr := func(v *api.SecureVariableMetadata) string {
if ns == "*" {
return fmt.Sprintf("%s|%s", v.Namespace, v.Path)
}
return v.Path
}
// Reduce the items slice to a string slice containing only the
// variable paths.
pList := make([]string, len(vars))
for i, sv := range vars {
pList[i] = toPathStr(sv)
}
return pList
}
func dataToQuietJSONReadySlice(vars []*api.SecureVariableMetadata, ns string) interface{} {
// If ns is the wildcard namespace, we have to provide namespace
// as part of the quiet output, otherwise it can be a simple list
// of paths.
if ns == "*" {
type pTuple struct {
Namespace string
Path string
}
pList := make([]*pTuple, len(vars))
for i, sv := range vars {
pList[i] = &pTuple{sv.Namespace, sv.Path}
}
return pList
}
// Reduce the items slice to a string slice containing only the
// variable paths.
pList := make([]string, len(vars))
for i, sv := range vars {
pList[i] = sv.Path
}
return pList
}

489
command/var_list_test.go Normal file
View File

@@ -0,0 +1,489 @@
package command
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/require"
)
func TestVarListCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &VarListCommand{}
}
// TestVarListCommand_Offline contains all of the tests that do not require a
// testagent to complete
func TestVarListCommand_Offline(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarListCommand{Meta: Meta{Ui: ui}}
testCases := []testVarListTestCase{
{
name: "help",
args: []string{"-help"},
exitCode: 1,
expectUsage: true,
},
{
name: "bad args",
args: []string{"some", "bad", "args"},
exitCode: 1,
expectUsageError: true,
expectStdErrPrefix: "This command takes flags and either no arguments or one: <prefix>",
},
{
name: "bad address",
args: []string{"-address", "nope"},
exitCode: 1,
expectStdErrPrefix: "Error retrieving vars",
},
{
name: "unparsable address",
args: []string{"-address", "http://10.0.0.1:bad"},
exitCode: 1,
expectStdErrPrefix: "Error initializing client: invalid address",
},
}
for _, tC := range testCases {
t.Run(tC.name, func(t *testing.T) {
tC := tC
ec := cmd.Run(tC.args)
stdOut := ui.OutputWriter.String()
errOut := ui.ErrorWriter.String()
defer resetUiWriters(ui)
require.Equal(t, tC.exitCode, ec,
"Expected exit code %v; got: %v\nstdout: %s\nstderr: %s",
tC.exitCode, ec, stdOut, errOut,
)
if tC.expectUsage {
help := cmd.Help()
require.Equal(t, help, strings.TrimSpace(stdOut))
// Test that stdout ends with a linefeed since we trim them for
// convenience in the equality tests.
require.True(t, strings.HasSuffix(stdOut, "\n"),
"stdout does not end with a linefeed")
}
if tC.expectUsageError {
require.Contains(t, errOut, commandErrorText(cmd))
}
if tC.expectStdOut != "" {
require.Equal(t, tC.expectStdOut, strings.TrimSpace(stdOut))
// Test that stdout ends with a linefeed since we trim them for
// convenience in the equality tests.
require.True(t, strings.HasSuffix(stdOut, "\n"),
"stdout does not end with a linefeed")
}
if tC.expectStdErrPrefix != "" {
require.True(t, strings.HasPrefix(errOut, tC.expectStdErrPrefix),
"Expected stderr to start with %q; got %s",
tC.expectStdErrPrefix, errOut)
// Test that stderr ends with a linefeed since we trim them for
// convenience in the equality tests.
require.True(t, strings.HasSuffix(errOut, "\n"),
"stderr does not end with a linefeed")
}
})
}
}
// TestVarListCommand_Online contains all of the tests that use a testagent.
// They reuse the same testagent so that they can run in parallel and minimize
// test startup time costs.
func TestVarListCommand_Online(t *testing.T) {
ci.Parallel(t)
// Create a server
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
ui := cli.NewMockUi()
cmd := &VarListCommand{Meta: Meta{Ui: ui}}
nsList := []string{api.DefaultNamespace, "ns1"}
pathList := []string{"a/b/c", "a/b/c/d", "z/y", "z/y/x"}
toJSON := func(in interface{}) string {
b, err := json.MarshalIndent(in, "", " ")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
variables := setupTestVariables(client, nsList, pathList)
testTmpl := `{{ range $i, $e := . }}{{if ne $i 0}}{{print "•"}}{{end}}{{printf "%v\t%v" .Namespace .Path}}{{end}}`
pathsEqual := func(t *testing.T, expect any) testVarListJSONTestExpectFn {
out := func(t *testing.T, check any) {
expect := expect
exp, ok := expect.(NSPather)
require.True(t, ok, "expect is not an NSPather, got %T", expect)
in, ok := check.(NSPather)
require.True(t, ok, "check is not an NSPather, got %T", check)
require.ElementsMatch(t, exp.NSPaths(), in.NSPaths())
}
return out
}
hasLength := func(t *testing.T, length int) testVarListJSONTestExpectFn {
out := func(t *testing.T, check any) {
length := length
in, ok := check.(NSPather)
require.True(t, ok, "check is not an NSPather, got %T", check)
inLen := in.NSPaths().Len()
require.Equal(t, length, inLen,
"expected length of %v, got %v. \nvalues: %v",
length, inLen, in.NSPaths())
}
return out
}
testCases := []testVarListTestCase{
{
name: "plaintext/not found",
args: []string{"does/not/exist"},
expectStdOut: msgSecureVariableNotFound,
},
{
name: "plaintext/single variable",
args: []string{"a/b/c/d"},
expectStdOut: formatList([]string{
"Namespace|Path|Last Updated",
fmt.Sprintf(
"default|a/b/c/d|%s",
time.Unix(0, variables.HavingPrefix("a/b/c/d")[0].ModifyTime),
),
},
),
},
{
name: "plaintext/quiet",
args: []string{"-q"},
expectStdOut: strings.Join(variables.HavingNamespace(api.DefaultNamespace).Strings(), "\n"),
},
{
name: "plaintext/quiet/prefix",
args: []string{"-q", "a/b/c"},
expectStdOut: strings.Join(variables.HavingNSPrefix(api.DefaultNamespace, "a/b/c").Strings(), "\n"),
},
{
name: "plaintext/quiet/filter",
args: []string{"-q", "-filter", "SecureVariableMetadata.Path == \"a/b/c\""},
expectStdOut: "a/b/c",
expectStdErrPrefix: msgWarnFilterPerformance,
},
{
name: "plaintext/quiet/paginated",
args: []string{"-q", "-per-page=1"},
expectStdOut: "a/b/c",
expectStdErrPrefix: "Next page token",
},
{
name: "plaintext/quiet/prefix/wildcard ns",
args: []string{"-q", "-namespace", "*", "a/b/c/d"},
expectStdOut: strings.Join(variables.HavingPrefix("a/b/c/d").Strings(), "\n"),
},
{
name: "plaintext/quiet/paginated/prefix/wildcard ns",
args: []string{"-q", "-per-page=1", "-namespace", "*", "a/b/c/d"},
expectStdOut: variables.HavingPrefix("a/b/c/d").Strings()[0],
expectStdErrPrefix: "Next page token",
},
{
name: "json/not found",
args: []string{"-json", "does/not/exist"},
jsonTest: &testVarListJSONTest{
jsonDest: &SVMSlice{},
expectFns: []testVarListJSONTestExpectFn{
hasLength(t, 0),
},
},
},
{
name: "json/prefix",
args: []string{"-json", "a"},
jsonTest: &testVarListJSONTest{
jsonDest: &SVMSlice{},
expectFns: []testVarListJSONTestExpectFn{
pathsEqual(t, variables.HavingNSPrefix(api.DefaultNamespace, "a")),
},
},
},
{
name: "json/paginated",
args: []string{"-json", "-per-page", "1"},
jsonTest: &testVarListJSONTest{
jsonDest: &PaginatedSVMSlice{},
expectFns: []testVarListJSONTestExpectFn{
hasLength(t, 1),
},
},
},
{
name: "json/quiet",
args: []string{"-q", "-json"},
expectStdOut: toJSON(variables.HavingNamespace(api.DefaultNamespace).Strings()),
},
{
name: "json/quiet/paginated",
args: []string{"-q", "-json", "-per-page", "1"},
jsonTest: &testVarListJSONTest{
jsonDest: &PaginatedSVQuietSlice{},
expectFns: []testVarListJSONTestExpectFn{
hasLength(t, 1),
},
},
},
{
name: "json/quiet/wildcard-ns",
args: []string{"-q", "-json", "-namespace", "*"},
jsonTest: &testVarListJSONTest{
jsonDest: &SVMSlice{},
expectFns: []testVarListJSONTestExpectFn{
hasLength(t, variables.Len()),
pathsEqual(t, variables),
},
},
},
{
name: "json/quiet/paginated/wildcard-ns",
args: []string{"-q", "-json", "-per-page=1", "-namespace", "*"},
jsonTest: &testVarListJSONTest{
jsonDest: &PaginatedSVMSlice{},
expectFns: []testVarListJSONTestExpectFn{
hasLength(t, 1),
pathsEqual(t, SVMSlice{variables[0]}),
},
},
},
{
name: "template/not found",
args: []string{"-t", testTmpl, "does/not/exist"},
expectStdOut: "",
},
{
name: "template/prefix",
args: []string{"-t", testTmpl, "a/b/c/d"},
expectStdOut: "default\ta/b/c/d",
},
{
name: "template/filter",
args: []string{"-t", testTmpl, "-filter", "SecureVariableMetadata.Path == \"a/b/c\""},
expectStdOut: "default\ta/b/c",
expectStdErrPrefix: msgWarnFilterPerformance,
},
{
name: "template/paginated",
args: []string{"-t", testTmpl, "-per-page=1"},
expectStdOut: "default\ta/b/c",
expectStdErrPrefix: "Next page token",
},
{
name: "template/prefix/wildcard namespace",
args: []string{"-namespace", "*", "-t", testTmpl, "a/b/c/d"},
expectStdOut: "default\ta/b/c/d•ns1\ta/b/c/d",
},
}
for _, tC := range testCases {
t.Run(tC.name, func(t *testing.T) {
tC := tC
// address always needs to be provided and since the test cases
// might pass a positional parameter, we need to jam it in the
// front.
tcArgs := append([]string{"-address=" + url}, tC.args...)
code := cmd.Run(tcArgs)
stdOut := ui.OutputWriter.String()
errOut := ui.ErrorWriter.String()
defer resetUiWriters(ui)
require.Equal(t, tC.exitCode, code,
"Expected exit code %v; got: %v\nstdout: %s\nstderr: %s",
tC.exitCode, code, stdOut, errOut)
if tC.expectStdOut != "" {
require.Equal(t, tC.expectStdOut, strings.TrimSpace(stdOut))
// Test that stdout ends with a linefeed since we trim them for
// convenience in the equality tests.
require.True(t, strings.HasSuffix(stdOut, "\n"),
"stdout does not end with a linefeed")
}
if tC.expectStdErrPrefix != "" {
require.True(t, strings.HasPrefix(errOut, tC.expectStdErrPrefix),
"Expected stderr to start with %q; got %s",
tC.expectStdErrPrefix, errOut)
// Test that stderr ends with a linefeed since this test only
// considers prefixes.
require.True(t, strings.HasSuffix(stdOut, "\n"),
"stderr does not end with a linefeed")
}
if tC.jsonTest != nil {
jtC := tC.jsonTest
err := json.Unmarshal([]byte(stdOut), &jtC.jsonDest)
require.NoError(t, err, "stdout: %s", stdOut)
for _, fn := range jtC.expectFns {
fn(t, jtC.jsonDest)
}
}
})
}
}
func resetUiWriters(ui *cli.MockUi) {
ui.ErrorWriter.Reset()
ui.OutputWriter.Reset()
}
type testVarListTestCase struct {
name string
args []string
exitCode int
expectUsage bool
expectUsageError bool
expectStdOut string
expectStdErrPrefix string
jsonTest *testVarListJSONTest
}
type testVarListJSONTest struct {
jsonDest interface{}
expectFns []testVarListJSONTestExpectFn
}
type testVarListJSONTestExpectFn func(*testing.T, interface{})
type testSVNamespacePath struct {
Namespace string
Path string
}
func setupTestVariables(c *api.Client, nsList, pathList []string) SVMSlice {
out := make(SVMSlice, 0, len(nsList)*len(pathList))
for _, ns := range nsList {
c.Namespaces().Register(&api.Namespace{Name: ns}, nil)
for _, p := range pathList {
setupTestVariable(c, ns, p, &out)
}
}
return out
}
func setupTestVariable(c *api.Client, ns, p string, out *SVMSlice) {
testVar := &api.SecureVariable{Items: map[string]string{"k": "v"}}
c.Raw().Write("/v1/var/"+p, testVar, nil, &api.WriteOptions{Namespace: ns})
v, _, _ := c.SecureVariables().Read(p, &api.QueryOptions{Namespace: ns})
*out = append(*out, *v.Metadata())
}
type NSPather interface {
Len() int
NSPaths() testSVNamespacePaths
}
type testSVNamespacePaths []testSVNamespacePath
func (ps testSVNamespacePaths) Len() int { return len(ps) }
func (ps testSVNamespacePaths) NSPaths() testSVNamespacePaths {
return ps
}
type SVMSlice []api.SecureVariableMetadata
func (s SVMSlice) Len() int { return len(s) }
func (s SVMSlice) NSPaths() testSVNamespacePaths {
out := make(testSVNamespacePaths, len(s))
for i, v := range s {
out[i] = testSVNamespacePath{v.Namespace, v.Path}
}
return out
}
func (ps SVMSlice) Strings() []string {
ns := make(map[string]struct{})
outNS := make([]string, len(ps))
out := make([]string, len(ps))
for i, p := range ps {
out[i] = p.Path
outNS[i] = p.Namespace + "|" + p.Path
ns[p.Namespace] = struct{}{}
}
if len(ns) > 1 {
return strings.Split(formatList(outNS), "\n")
}
return out
}
func (ps *SVMSlice) HavingNamespace(ns string) SVMSlice {
return *ps.having("namespace", ns)
}
func (ps *SVMSlice) HavingPrefix(prefix string) SVMSlice {
return *ps.having("prefix", prefix)
}
func (ps *SVMSlice) HavingNSPrefix(ns, p string) SVMSlice {
return *ps.having("namespace", ns).having("prefix", p)
}
func (ps SVMSlice) having(field, val string) *SVMSlice {
out := make(SVMSlice, 0, len(ps))
for _, p := range ps {
if field == "namespace" && p.Namespace == val {
out = append(out, p)
}
if field == "prefix" && strings.HasPrefix(p.Path, val) {
out = append(out, p)
}
}
return &out
}
type PaginatedSVMSlice struct {
Data SVMSlice
QueryMeta api.QueryMeta
}
func (s *PaginatedSVMSlice) Len() int { return len(s.Data) }
func (s *PaginatedSVMSlice) NSPaths() testSVNamespacePaths {
out := make(testSVNamespacePaths, len(s.Data))
for i, v := range s.Data {
out[i] = testSVNamespacePath{v.Namespace, v.Path}
}
return out
}
type PaginatedSVQuietSlice struct {
Data []string
QueryMeta api.QueryMeta
}
func (ps PaginatedSVQuietSlice) Len() int { return len(ps.Data) }
func (s *PaginatedSVQuietSlice) NSPaths() testSVNamespacePaths {
out := make(testSVNamespacePaths, len(s.Data))
for i, v := range s.Data {
out[i] = testSVNamespacePath{"", v}
}
return out
}