mirror of
https://github.com/kemko/nomad.git
synced 2026-01-06 18:35:44 +03:00
Merge branch 'main' into f-gh-13120-sso-umbrella
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
134
command/agent/keyring_endpoint.go
Normal file
134
command/agent/keyring_endpoint.go
Normal 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
|
||||
}
|
||||
103
command/agent/keyring_endpoint_test.go
Normal file
103
command/agent/keyring_endpoint_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
178
command/agent/secure_variable_endpoint.go
Normal file
178
command/agent/secure_variable_endpoint.go
Normal 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)
|
||||
}
|
||||
535
command/agent/secure_variable_endpoint_test.go
Normal file
535
command/agent/secure_variable_endpoint_test.go
Normal 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))
|
||||
}
|
||||
7
command/agent/testdata/basic.hcl
vendored
7
command/agent/testdata/basic.hcl
vendored
@@ -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 {
|
||||
|
||||
6
command/agent/testdata/basic.json
vendored
6
command/agent/testdata/basic.json
vendored
@@ -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",
|
||||
|
||||
4
command/agent/testdata/sample0.json
vendored
4
command/agent/testdata/sample0.json
vendored
@@ -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",
|
||||
|
||||
4
command/agent/testdata/sample1/sample0.json
vendored
4
command/agent/testdata/sample1/sample0.json
vendored
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
72
command/operator_gossip_keyring.go
Normal file
72
command/operator_gossip_keyring.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
87
command/operator_gossip_keyring_install.go
Normal file
87
command/operator_gossip_keyring_install.go
Normal 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
|
||||
}
|
||||
98
command/operator_gossip_keyring_list.go
Normal file
98
command/operator_gossip_keyring_list.go
Normal 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))
|
||||
}
|
||||
87
command/operator_gossip_keyring_remove.go
Normal file
87
command/operator_gossip_keyring_remove.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
87
command/operator_gossip_keyring_use.go
Normal file
87
command/operator_gossip_keyring_use.go
Normal 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
|
||||
}
|
||||
@@ -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: "==> ",
|
||||
|
||||
87
command/operator_secure_variables_keyring.go
Normal file
87
command/operator_secure_variables_keyring.go
Normal 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)
|
||||
}
|
||||
114
command/operator_secure_variables_keyring_install.go
Normal file
114
command/operator_secure_variables_keyring_install.go
Normal 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
|
||||
}
|
||||
88
command/operator_secure_variables_keyring_list.go
Normal file
88
command/operator_secure_variables_keyring_list.go
Normal 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
|
||||
}
|
||||
83
command/operator_secure_variables_keyring_remove.go
Normal file
83
command/operator_secure_variables_keyring_remove.go
Normal 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
|
||||
}
|
||||
96
command/operator_secure_variables_keyring_rotate.go
Normal file
96
command/operator_secure_variables_keyring_rotate.go
Normal 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
|
||||
}
|
||||
@@ -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
74
command/var.go
Normal 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
276
command/var_list.go
Normal 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
489
command/var_list_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user