Merge remote-tracking branch 'origin/master' into bugfix/prevent-multi-cname

This commit is contained in:
Matt Keeler 2018-07-10 10:26:45 -04:00
commit 0fd7e97c2d
104 changed files with 1890 additions and 654 deletions

View File

@ -1,11 +1,17 @@
## UNRELEASED
IMPROVEMENTS:
* acl: Prevented multiple ACL token refresh operations from occurring simultaneously. [[GH-3524](https://github.com/hashicorp/consul/issues/3524)]
* acl: Add async-cache down policy mode to always do ACL token refreshes in the background to reduce latency. [[GH-3524](https://github.com/hashicorp/consul/issues/3524)]
BUG FIXES:
* api: Intention APIs parse error response body for error message. [[GH-4297](https://github.com/hashicorp/consul/issues/4297)]
* agent: Intention read endpoint returns a 400 on invalid UUID [[GH-4297](https://github.com/hashicorp/consul/issues/4297)]
* agent: Service registration with "services" does not error on Connect upstream configuration. [[GH-4308](https://github.com/hashicorp/consul/issues/4308)]
* catalog: Ensure all registered services have IDs and auto-gen them if need be. [[GH-4249](https://github.com/hashicorp/consul/issues/4249)]
* dns: Ensure that TXT RRs dont get put in the Answer section for A/AAAA queries. [[GH-4354](https://github.com/hashicorp/consul/issues/4354)]
## 1.2.0 (June 26, 2018)

View File

@ -158,7 +158,7 @@ test: other-consul dev-build vet
@# hide it from travis as it exceeds their log limits and causes job to be
@# terminated (over 4MB and over 10k lines in the UI). We need to output
@# _something_ to stop them terminating us due to inactivity...
{ go test $(GOTEST_FLAGS) -tags '$(GOTAGS)' -timeout 5m $(GOTEST_PKGS) 2>&1 ; echo $$? > exit-code ; } | tee test.log | egrep '^(ok|FAIL)\s*github.com/hashicorp/consul'
{ go test $(GOTEST_FLAGS) -tags '$(GOTAGS)' -timeout 7m $(GOTEST_PKGS) 2>&1 ; echo $$? > exit-code ; } | tee test.log | egrep '^(ok|FAIL)\s*github.com/hashicorp/consul'
@echo "Exit code: $$(cat exit-code)" >> test.log
@# This prints all the race report between ====== lines
@awk '/^WARNING: DATA RACE/ {do_print=1; print "=================="} do_print==1 {print} /^={10,}/ {do_print=0}' test.log || true

View File

@ -103,7 +103,7 @@ func newACLManager(config *config.RuntimeConfig) (*aclManager, error) {
down = acl.AllowAll()
case "deny":
down = acl.DenyAll()
case "extend-cache":
case "async-cache", "extend-cache":
// Leave the down policy as nil to signal this.
default:
return nil, fmt.Errorf("invalid ACL down policy %q", config.ACLDownPolicy)

View File

@ -274,79 +274,82 @@ func TestACL_Down_Allow(t *testing.T) {
func TestACL_Down_Extend(t *testing.T) {
t.Parallel()
a := NewTestAgent(t.Name(), TestACLConfig()+`
acl_down_policy = "extend-cache"
acl_enforce_version_8 = true
`)
defer a.Shutdown()
aclExtendPolicies := []string{"extend-cache", "async-cache"}
for _, aclDownPolicy := range aclExtendPolicies {
a := NewTestAgent(t.Name(), TestACLConfig()+`
acl_down_policy = "`+aclDownPolicy+`"
acl_enforce_version_8 = true
`)
defer a.Shutdown()
m := MockServer{
// Populate the cache for one of the tokens.
getPolicyFn: func(req *structs.ACLPolicyRequest, reply *structs.ACLPolicy) error {
*reply = structs.ACLPolicy{
Parent: "allow",
Policy: &rawacl.Policy{
Agents: []*rawacl.AgentPolicy{
&rawacl.AgentPolicy{
Node: a.config.NodeName,
Policy: "read",
m := MockServer{
// Populate the cache for one of the tokens.
getPolicyFn: func(req *structs.ACLPolicyRequest, reply *structs.ACLPolicy) error {
*reply = structs.ACLPolicy{
Parent: "allow",
Policy: &rawacl.Policy{
Agents: []*rawacl.AgentPolicy{
&rawacl.AgentPolicy{
Node: a.config.NodeName,
Policy: "read",
},
},
},
},
}
return nil
},
}
if err := a.registerEndpoint("ACL", &m); err != nil {
t.Fatalf("err: %v", err)
}
}
return nil
},
}
if err := a.registerEndpoint("ACL", &m); err != nil {
t.Fatalf("err: %v", err)
}
acl, err := a.resolveToken("yep")
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("should not be nil")
}
if !acl.AgentRead(a.config.NodeName) {
t.Fatalf("should allow")
}
if acl.AgentWrite(a.config.NodeName) {
t.Fatalf("should deny")
}
acl, err := a.resolveToken("yep")
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("should not be nil")
}
if !acl.AgentRead(a.config.NodeName) {
t.Fatalf("should allow")
}
if acl.AgentWrite(a.config.NodeName) {
t.Fatalf("should deny")
}
// Now take down ACLs and make sure a new token fails to resolve.
m.getPolicyFn = func(*structs.ACLPolicyRequest, *structs.ACLPolicy) error {
return fmt.Errorf("ACLs are broken")
}
acl, err = a.resolveToken("nope")
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("should not be nil")
}
if acl.AgentRead(a.config.NodeName) {
t.Fatalf("should deny")
}
if acl.AgentWrite(a.config.NodeName) {
t.Fatalf("should deny")
}
// Now take down ACLs and make sure a new token fails to resolve.
m.getPolicyFn = func(*structs.ACLPolicyRequest, *structs.ACLPolicy) error {
return fmt.Errorf("ACLs are broken")
}
acl, err = a.resolveToken("nope")
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("should not be nil")
}
if acl.AgentRead(a.config.NodeName) {
t.Fatalf("should deny")
}
if acl.AgentWrite(a.config.NodeName) {
t.Fatalf("should deny")
}
// Read the token from the cache while ACLs are broken, which should
// extend.
acl, err = a.resolveToken("yep")
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("should not be nil")
}
if !acl.AgentRead(a.config.NodeName) {
t.Fatalf("should allow")
}
if acl.AgentWrite(a.config.NodeName) {
t.Fatalf("should deny")
// Read the token from the cache while ACLs are broken, which should
// extend.
acl, err = a.resolveToken("yep")
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("should not be nil")
}
if !acl.AgentRead(a.config.NodeName) {
t.Fatalf("should allow")
}
if acl.AgentWrite(a.config.NodeName) {
t.Fatalf("should deny")
}
}
}

View File

@ -94,8 +94,10 @@ type RuntimeConfig struct {
// ACL's to be used to service requests. This
// is the default. If the ACL is not in the cache,
// this acts like deny.
// * async-cache - Same behaviour as extend-cache, but perform ACL
// Lookups asynchronously when cache TTL is expired.
//
// hcl: acl_down_policy = ("allow"|"deny"|"extend-cache")
// hcl: acl_down_policy = ("allow"|"deny"|"extend-cache"|"async-cache")
ACLDownPolicy string
// ACLEnforceVersion8 is used to gate a set of ACL policy features that

View File

@ -4,6 +4,7 @@ import (
"fmt"
"log"
"os"
"sync"
"time"
"github.com/armon/go-metrics"
@ -116,6 +117,9 @@ type aclCache struct {
// local is a function used to look for an ACL locally if replication is
// enabled. This will be nil if replication isn't enabled.
local acl.FaultFunc
fetchMutex sync.RWMutex
fetchMap map[string][]chan (RemoteACLResult)
}
// newACLCache returns a new non-authoritative cache for ACLs. This is used for
@ -142,10 +146,17 @@ func newACLCache(conf *Config, logger *log.Logger, rpc rpcFn, local acl.FaultFun
if err != nil {
return nil, fmt.Errorf("Failed to create ACL policy cache: %v", err)
}
cache.fetchMap = make(map[string][]chan (RemoteACLResult))
return cache, nil
}
// Result Type returned when fetching Remote ACLs asynchronously
type RemoteACLResult struct {
result acl.ACL
err error
}
// lookupACL is used when we are non-authoritative, and need to resolve an ACL.
func (c *aclCache) lookupACL(id, authDC string) (acl.ACL, error) {
// Check the cache for the ACL.
@ -161,8 +172,23 @@ func (c *aclCache) lookupACL(id, authDC string) (acl.ACL, error) {
return cached.ACL, nil
}
metrics.IncrCounter([]string{"acl", "cache_miss"}, 1)
res := c.lookupACLRemote(id, authDC, cached)
return res.result, res.err
}
// Attempt to refresh the policy from the ACL datacenter via an RPC.
func (c *aclCache) fireResult(id string, theACL acl.ACL, err error) {
c.fetchMutex.Lock()
channels := c.fetchMap[id]
delete(c.fetchMap, id)
c.fetchMutex.Unlock()
aclResult := RemoteACLResult{theACL, err}
for _, cx := range channels {
cx <- aclResult
close(cx)
}
}
func (c *aclCache) loadACLInChan(id, authDC string, cached *aclCacheEntry) {
args := structs.ACLPolicyRequest{
Datacenter: authDC,
ACL: id,
@ -173,13 +199,21 @@ func (c *aclCache) lookupACL(id, authDC string) (acl.ACL, error) {
var reply structs.ACLPolicy
err := c.rpc("ACL.GetPolicy", &args, &reply)
if err == nil {
return c.useACLPolicy(id, authDC, cached, &reply)
theACL, theError := c.useACLPolicy(id, authDC, cached, &reply)
if cached != nil && theACL != nil {
cached.ACL = theACL
cached.ETag = reply.ETag
cached.Expires = time.Now().Add(c.config.ACLTTL)
}
c.fireResult(id, theACL, theError)
return
}
// Check for not-found, which will cause us to bail immediately. For any
// other error we report it in the logs but can continue.
if acl.IsErrNotFound(err) {
return nil, acl.ErrNotFound
c.fireResult(id, nil, acl.ErrNotFound)
return
}
c.logger.Printf("[ERR] consul.acl: Failed to get policy from ACL datacenter: %v", err)
@ -200,7 +234,7 @@ func (c *aclCache) lookupACL(id, authDC string) (acl.ACL, error) {
// local ACL fault function is registered to query replicated ACL data,
// and the user's policy allows it, we will try locally before we give
// up.
if c.local != nil && c.config.ACLDownPolicy == "extend-cache" {
if c.local != nil && (c.config.ACLDownPolicy == "extend-cache" || c.config.ACLDownPolicy == "async-cache") {
parent, rules, err := c.local(id)
if err != nil {
// We don't make an exception here for ACLs that aren't
@ -227,24 +261,58 @@ func (c *aclCache) lookupACL(id, authDC string) (acl.ACL, error) {
reply.TTL = c.config.ACLTTL
reply.Parent = parent
reply.Policy = policy
return c.useACLPolicy(id, authDC, cached, &reply)
theACL, theError := c.useACLPolicy(id, authDC, cached, &reply)
if cached != nil && theACL != nil {
cached.ACL = theACL
cached.ETag = reply.ETag
cached.Expires = time.Now().Add(c.config.ACLTTL)
}
c.fireResult(id, theACL, theError)
return
}
ACL_DOWN:
// Unable to refresh, apply the down policy.
switch c.config.ACLDownPolicy {
case "allow":
return acl.AllowAll(), nil
case "extend-cache":
c.fireResult(id, acl.AllowAll(), nil)
return
case "async-cache", "extend-cache":
if cached != nil {
return cached.ACL, nil
c.fireResult(id, cached.ACL, nil)
return
}
fallthrough
default:
return acl.DenyAll(), nil
c.fireResult(id, acl.DenyAll(), nil)
return
}
}
func (c *aclCache) lookupACLRemote(id, authDC string, cached *aclCacheEntry) RemoteACLResult {
// Attempt to refresh the policy from the ACL datacenter via an RPC.
myChan := make(chan RemoteACLResult)
mustWaitForResult := cached == nil || c.config.ACLDownPolicy != "async-cache"
c.fetchMutex.Lock()
clients, ok := c.fetchMap[id]
if !ok || clients == nil {
clients = make([]chan RemoteACLResult, 0)
}
if mustWaitForResult {
c.fetchMap[id] = append(clients, myChan)
}
c.fetchMutex.Unlock()
if !ok {
go c.loadACLInChan(id, authDC, cached)
}
if !mustWaitForResult {
return RemoteACLResult{cached.ACL, nil}
}
res := <-myChan
return res
}
// useACLPolicy handles an ACLPolicy response
func (c *aclCache) useACLPolicy(id, authDC string, cached *aclCacheEntry, p *structs.ACLPolicy) (acl.ACL, error) {
// Check if we can used the cached policy

View File

@ -508,193 +508,201 @@ func TestACL_DownPolicy_Allow(t *testing.T) {
func TestACL_DownPolicy_ExtendCache(t *testing.T) {
t.Parallel()
dir1, s1 := testServerWithConfig(t, func(c *Config) {
c.ACLDatacenter = "dc1"
c.ACLTTL = 0
c.ACLDownPolicy = "extend-cache"
c.ACLMasterToken = "root"
})
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
aclExtendPolicies := []string{"extend-cache", "async-cache"} //"async-cache"
dir2, s2 := testServerWithConfig(t, func(c *Config) {
c.ACLDatacenter = "dc1" // Enable ACLs!
c.ACLTTL = 0
c.ACLDownPolicy = "extend-cache"
c.Bootstrap = false // Disable bootstrap
})
defer os.RemoveAll(dir2)
defer s2.Shutdown()
for _, aclDownPolicy := range aclExtendPolicies {
dir1, s1 := testServerWithConfig(t, func(c *Config) {
c.ACLDatacenter = "dc1"
c.ACLTTL = 0
c.ACLDownPolicy = aclDownPolicy
c.ACLMasterToken = "root"
})
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
// Try to join
joinLAN(t, s2, s1)
retry.Run(t, func(r *retry.R) { r.Check(wantRaft([]*Server{s1, s2})) })
dir2, s2 := testServerWithConfig(t, func(c *Config) {
c.ACLDatacenter = "dc1" // Enable ACLs!
c.ACLTTL = 0
c.ACLDownPolicy = aclDownPolicy
c.Bootstrap = false // Disable bootstrap
})
defer os.RemoveAll(dir2)
defer s2.Shutdown()
testrpc.WaitForLeader(t, s1.RPC, "dc1")
// Try to join
joinLAN(t, s2, s1)
retry.Run(t, func(r *retry.R) { r.Check(wantRaft([]*Server{s1, s2})) })
// Create a new token
arg := structs.ACLRequest{
Datacenter: "dc1",
Op: structs.ACLSet,
ACL: structs.ACL{
Name: "User token",
Type: structs.ACLTypeClient,
Rules: testACLPolicy,
},
WriteRequest: structs.WriteRequest{Token: "root"},
}
var id string
if err := s1.RPC("ACL.Apply", &arg, &id); err != nil {
t.Fatalf("err: %v", err)
}
testrpc.WaitForLeader(t, s1.RPC, "dc1")
// find the non-authoritative server
var nonAuth *Server
var auth *Server
if !s1.IsLeader() {
nonAuth = s1
auth = s2
} else {
nonAuth = s2
auth = s1
}
// Create a new token
arg := structs.ACLRequest{
Datacenter: "dc1",
Op: structs.ACLSet,
ACL: structs.ACL{
Name: "User token",
Type: structs.ACLTypeClient,
Rules: testACLPolicy,
},
WriteRequest: structs.WriteRequest{Token: "root"},
}
var id string
if err := s1.RPC("ACL.Apply", &arg, &id); err != nil {
t.Fatalf("err: %v", err)
}
// Warm the caches
aclR, err := nonAuth.resolveToken(id)
if err != nil {
t.Fatalf("err: %v", err)
}
if aclR == nil {
t.Fatalf("bad acl: %#v", aclR)
}
// find the non-authoritative server
var nonAuth *Server
var auth *Server
if !s1.IsLeader() {
nonAuth = s1
auth = s2
} else {
nonAuth = s2
auth = s1
}
// Kill the authoritative server
auth.Shutdown()
// Warm the caches
aclR, err := nonAuth.resolveToken(id)
if err != nil {
t.Fatalf("err: %v", err)
}
if aclR == nil {
t.Fatalf("bad acl: %#v", aclR)
}
// Token should resolve into cached copy
aclR2, err := nonAuth.resolveToken(id)
if err != nil {
t.Fatalf("err: %v", err)
}
if aclR2 != aclR {
t.Fatalf("bad acl: %#v", aclR)
// Kill the authoritative server
auth.Shutdown()
// Token should resolve into cached copy
aclR2, err := nonAuth.resolveToken(id)
if err != nil {
t.Fatalf("err: %v", err)
}
if aclR2 != aclR {
t.Fatalf("bad acl: %#v", aclR)
}
}
}
func TestACL_Replication(t *testing.T) {
t.Parallel()
dir1, s1 := testServerWithConfig(t, func(c *Config) {
c.ACLDatacenter = "dc1"
c.ACLMasterToken = "root"
})
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
aclExtendPolicies := []string{"extend-cache", "async-cache"} //"async-cache"
dir2, s2 := testServerWithConfig(t, func(c *Config) {
c.Datacenter = "dc2"
c.ACLDatacenter = "dc1"
c.ACLDefaultPolicy = "deny"
c.ACLDownPolicy = "extend-cache"
c.EnableACLReplication = true
c.ACLReplicationInterval = 10 * time.Millisecond
c.ACLReplicationApplyLimit = 1000000
})
s2.tokens.UpdateACLReplicationToken("root")
defer os.RemoveAll(dir2)
defer s2.Shutdown()
for _, aclDownPolicy := range aclExtendPolicies {
dir1, s1 := testServerWithConfig(t, func(c *Config) {
c.ACLDatacenter = "dc1"
c.ACLMasterToken = "root"
})
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
dir3, s3 := testServerWithConfig(t, func(c *Config) {
c.Datacenter = "dc3"
c.ACLDatacenter = "dc1"
c.ACLDownPolicy = "deny"
c.EnableACLReplication = true
c.ACLReplicationInterval = 10 * time.Millisecond
c.ACLReplicationApplyLimit = 1000000
})
s3.tokens.UpdateACLReplicationToken("root")
defer os.RemoveAll(dir3)
defer s3.Shutdown()
dir2, s2 := testServerWithConfig(t, func(c *Config) {
c.Datacenter = "dc2"
c.ACLDatacenter = "dc1"
c.ACLDefaultPolicy = "deny"
c.ACLDownPolicy = aclDownPolicy
c.EnableACLReplication = true
c.ACLReplicationInterval = 10 * time.Millisecond
c.ACLReplicationApplyLimit = 1000000
})
s2.tokens.UpdateACLReplicationToken("root")
defer os.RemoveAll(dir2)
defer s2.Shutdown()
// Try to join.
joinWAN(t, s2, s1)
joinWAN(t, s3, s1)
testrpc.WaitForLeader(t, s1.RPC, "dc1")
testrpc.WaitForLeader(t, s1.RPC, "dc2")
testrpc.WaitForLeader(t, s1.RPC, "dc3")
dir3, s3 := testServerWithConfig(t, func(c *Config) {
c.Datacenter = "dc3"
c.ACLDatacenter = "dc1"
c.ACLDownPolicy = "deny"
c.EnableACLReplication = true
c.ACLReplicationInterval = 10 * time.Millisecond
c.ACLReplicationApplyLimit = 1000000
})
s3.tokens.UpdateACLReplicationToken("root")
defer os.RemoveAll(dir3)
defer s3.Shutdown()
// Create a new token.
arg := structs.ACLRequest{
Datacenter: "dc1",
Op: structs.ACLSet,
ACL: structs.ACL{
Name: "User token",
Type: structs.ACLTypeClient,
Rules: testACLPolicy,
},
WriteRequest: structs.WriteRequest{Token: "root"},
}
var id string
if err := s1.RPC("ACL.Apply", &arg, &id); err != nil {
t.Fatalf("err: %v", err)
}
// Wait for replication to occur.
retry.Run(t, func(r *retry.R) {
_, acl, err := s2.fsm.State().ACLGet(nil, id)
// Try to join.
joinWAN(t, s2, s1)
joinWAN(t, s3, s1)
testrpc.WaitForLeader(t, s1.RPC, "dc1")
testrpc.WaitForLeader(t, s1.RPC, "dc2")
testrpc.WaitForLeader(t, s1.RPC, "dc3")
// Create a new token.
arg := structs.ACLRequest{
Datacenter: "dc1",
Op: structs.ACLSet,
ACL: structs.ACL{
Name: "User token",
Type: structs.ACLTypeClient,
Rules: testACLPolicy,
},
WriteRequest: structs.WriteRequest{Token: "root"},
}
var id string
if err := s1.RPC("ACL.Apply", &arg, &id); err != nil {
t.Fatalf("err: %v", err)
}
// Wait for replication to occur.
retry.Run(t, func(r *retry.R) {
_, acl, err := s2.fsm.State().ACLGet(nil, id)
if err != nil {
r.Fatal(err)
}
if acl == nil {
r.Fatal(nil)
}
_, acl, err = s3.fsm.State().ACLGet(nil, id)
if err != nil {
r.Fatal(err)
}
if acl == nil {
r.Fatal(nil)
}
})
// Kill the ACL datacenter.
s1.Shutdown()
// Token should resolve on s2, which has replication + extend-cache.
acl, err := s2.resolveToken(id)
if err != nil {
r.Fatal(err)
t.Fatalf("err: %v", err)
}
if acl == nil {
r.Fatal(nil)
t.Fatalf("missing acl")
}
_, acl, err = s3.fsm.State().ACLGet(nil, id)
// Check the policy
if acl.KeyRead("bar") {
t.Fatalf("unexpected read")
}
if !acl.KeyRead("foo/test") {
t.Fatalf("unexpected failed read")
}
// Although s3 has replication, and we verified that the ACL is there,
// it can not be used because of the down policy.
acl, err = s3.resolveToken(id)
if err != nil {
r.Fatal(err)
t.Fatalf("err: %v", err)
}
if acl == nil {
r.Fatal(nil)
t.Fatalf("missing acl")
}
})
// Kill the ACL datacenter.
s1.Shutdown()
// Token should resolve on s2, which has replication + extend-cache.
acl, err := s2.resolveToken(id)
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("missing acl")
}
// Check the policy
if acl.KeyRead("bar") {
t.Fatalf("unexpected read")
}
if !acl.KeyRead("foo/test") {
t.Fatalf("unexpected failed read")
}
// Although s3 has replication, and we verified that the ACL is there,
// it can not be used because of the down policy.
acl, err = s3.resolveToken(id)
if err != nil {
t.Fatalf("err: %v", err)
}
if acl == nil {
t.Fatalf("missing acl")
}
// Check the policy.
if acl.KeyRead("bar") {
t.Fatalf("unexpected read")
}
if acl.KeyRead("foo/test") {
t.Fatalf("unexpected read")
// Check the policy.
if acl.KeyRead("bar") {
t.Fatalf("unexpected read")
}
if acl.KeyRead("foo/test") {
t.Fatalf("unexpected read")
}
}
}

View File

@ -235,8 +235,9 @@ type Config struct {
// ACLDownPolicy controls the behavior of ACLs if the ACLDatacenter
// cannot be contacted. It can be either "deny" to deny all requests,
// or "extend-cache" which ignores the ACLCacheInterval and uses
// cached policies. If a policy is not in the cache, it acts like deny.
// "extend-cache" or "async-cache" which ignores the ACLCacheInterval and
// uses cached policies.
// If a policy is not in the cache, it acts like deny.
// "allow" can be used to allow all requests. This is not recommended.
ACLDownPolicy string
@ -378,7 +379,7 @@ func (c *Config) CheckACL() error {
switch c.ACLDownPolicy {
case "allow":
case "deny":
case "extend-cache":
case "async-cache", "extend-cache":
default:
return fmt.Errorf("Unsupported down ACL policy: %s", c.ACLDownPolicy)
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"strings"
"time"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/connect"
@ -177,6 +178,7 @@ func (s *ConnectCA) ConfigurationSet(
newRoot := *r
if newRoot.Active {
newRoot.Active = false
newRoot.RotatedOutAt = time.Now()
}
newRoots = append(newRoots, &newRoot)
}

View File

@ -28,7 +28,18 @@ const (
barrierWriteTimeout = 2 * time.Minute
)
var minAutopilotVersion = version.Must(version.NewVersion("0.8.0"))
var (
// caRootPruneInterval is how often we check for stale CARoots to remove.
caRootPruneInterval = time.Hour
// caRootExpireDuration is the duration after which an inactive root is considered
// "expired". Currently this is based on the default leaf cert TTL of 3 days.
caRootExpireDuration = 7 * 24 * time.Hour
// minAutopilotVersion is the minimum Consul version in which Autopilot features
// are supported.
minAutopilotVersion = version.Must(version.NewVersion("0.8.0"))
)
// monitorLeadership is used to monitor if we acquire or lose our role
// as the leader in the Raft cluster. There is some work the leader is
@ -220,6 +231,8 @@ func (s *Server) establishLeadership() error {
return err
}
s.startCARootPruning()
s.setConsistentReadReady()
return nil
}
@ -236,6 +249,8 @@ func (s *Server) revokeLeadership() error {
return err
}
s.stopCARootPruning()
s.setCAProvider(nil, nil)
s.resetConsistentReadReady()
@ -550,6 +565,92 @@ func (s *Server) setCAProvider(newProvider ca.Provider, root *structs.CARoot) {
s.caProviderRoot = root
}
// startCARootPruning starts a goroutine that looks for stale CARoots
// and removes them from the state store.
func (s *Server) startCARootPruning() {
s.caPruningLock.Lock()
defer s.caPruningLock.Unlock()
if s.caPruningEnabled {
return
}
s.caPruningCh = make(chan struct{})
go func() {
ticker := time.NewTicker(caRootPruneInterval)
defer ticker.Stop()
for {
select {
case <-s.caPruningCh:
return
case <-ticker.C:
if err := s.pruneCARoots(); err != nil {
s.logger.Printf("[ERR] connect: error pruning CA roots: %v", err)
}
}
}
}()
s.caPruningEnabled = true
}
// pruneCARoots looks for any CARoots that have been rotated out and expired.
func (s *Server) pruneCARoots() error {
if !s.config.ConnectEnabled {
return nil
}
idx, roots, err := s.fsm.State().CARoots(nil)
if err != nil {
return err
}
var newRoots structs.CARoots
for _, r := range roots {
if !r.Active && !r.RotatedOutAt.IsZero() && time.Now().Sub(r.RotatedOutAt) > caRootExpireDuration {
s.logger.Printf("[INFO] connect: pruning old unused root CA (ID: %s)", r.ID)
continue
}
newRoot := *r
newRoots = append(newRoots, &newRoot)
}
// Return early if there's nothing to remove.
if len(newRoots) == len(roots) {
return nil
}
// Commit the new root state.
var args structs.CARequest
args.Op = structs.CAOpSetRoots
args.Index = idx
args.Roots = newRoots
resp, err := s.raftApply(structs.ConnectCARequestType, args)
if err != nil {
return err
}
if respErr, ok := resp.(error); ok {
return respErr
}
return nil
}
// stopCARootPruning stops the CARoot pruning process.
func (s *Server) stopCARootPruning() {
s.caPruningLock.Lock()
defer s.caPruningLock.Unlock()
if !s.caPruningEnabled {
return
}
close(s.caPruningCh)
s.caPruningEnabled = false
}
// reconcileReaped is used to reconcile nodes that have failed and been reaped
// from Serf but remain in the catalog. This is done by looking for unknown nodes with serfHealth checks registered.
// We generate a "reap" event to cause the node to be cleaned up.

View File

@ -5,12 +5,14 @@ import (
"testing"
"time"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/testrpc"
"github.com/hashicorp/consul/testutil/retry"
"github.com/hashicorp/net-rpc-msgpackrpc"
"github.com/hashicorp/serf/serf"
"github.com/stretchr/testify/require"
)
func TestLeader_RegisterMember(t *testing.T) {
@ -1001,3 +1003,64 @@ func TestLeader_ACL_Initialization(t *testing.T) {
})
}
}
func TestLeader_CARootPruning(t *testing.T) {
t.Parallel()
caRootExpireDuration = 500 * time.Millisecond
caRootPruneInterval = 200 * time.Millisecond
require := require.New(t)
dir1, s1 := testServer(t)
defer os.RemoveAll(dir1)
defer s1.Shutdown()
codec := rpcClient(t, s1)
defer codec.Close()
testrpc.WaitForLeader(t, s1.RPC, "dc1")
// Get the current root
rootReq := &structs.DCSpecificRequest{
Datacenter: "dc1",
}
var rootList structs.IndexedCARoots
require.Nil(msgpackrpc.CallWithCodec(codec, "ConnectCA.Roots", rootReq, &rootList))
require.Len(rootList.Roots, 1)
oldRoot := rootList.Roots[0]
// Update the provider config to use a new private key, which should
// cause a rotation.
_, newKey, err := connect.GeneratePrivateKey()
require.NoError(err)
newConfig := &structs.CAConfiguration{
Provider: "consul",
Config: map[string]interface{}{
"PrivateKey": newKey,
"RootCert": "",
"RotationPeriod": 90 * 24 * time.Hour,
},
}
{
args := &structs.CARequest{
Datacenter: "dc1",
Config: newConfig,
}
var reply interface{}
require.NoError(msgpackrpc.CallWithCodec(codec, "ConnectCA.ConfigurationSet", args, &reply))
}
// Should have 2 roots now.
_, roots, err := s1.fsm.State().CARoots(nil)
require.NoError(err)
require.Len(roots, 2)
time.Sleep(caRootExpireDuration * 2)
// Now the old root should be pruned.
_, roots, err = s1.fsm.State().CARoots(nil)
require.NoError(err)
require.Len(roots, 1)
require.True(roots[0].Active)
require.NotEqual(roots[0].ID, oldRoot.ID)
}

View File

@ -107,6 +107,12 @@ type Server struct {
caProviderRoot *structs.CARoot
caProviderLock sync.RWMutex
// caPruningCh is used to shut down the CA root pruning goroutine when we
// lose leadership.
caPruningCh chan struct{}
caPruningLock sync.RWMutex
caPruningEnabled bool
// Consul configuration
config *Config
@ -421,7 +427,7 @@ func NewServerLogger(config *Config, logger *log.Logger, tokens *token.Store) (*
}
go s.Flood(nil, portFn, s.serfWAN)
}
// Start enterprise specific functionality
if err := s.startEnterprise(); err != nil {
s.Shutdown()

View File

@ -376,8 +376,11 @@ func (d *DNSServer) nameservers(edns bool) (ns []dns.RR, extra []dns.RR) {
}
ns = append(ns, nsrr)
glue := d.formatNodeRecord(nil, addr, fqdn, dns.TypeANY, d.config.NodeTTL, edns, false)
glue, meta := d.formatNodeRecord(nil, addr, fqdn, dns.TypeANY, d.config.NodeTTL, edns)
extra = append(extra, glue...)
if meta != nil && d.config.NodeMetaTXT {
extra = append(extra, meta...)
}
// don't provide more than 3 servers
if len(ns) >= 3 {
@ -592,10 +595,15 @@ RPC:
n := out.NodeServices.Node
edns := req.IsEdns0() != nil
addr := d.agent.TranslateAddress(datacenter, n.Address, n.TaggedAddresses)
records := d.formatNodeRecord(out.NodeServices.Node, addr, req.Question[0].Name, qType, d.config.NodeTTL, edns, true)
records, meta := d.formatNodeRecord(out.NodeServices.Node, addr, req.Question[0].Name, qType, d.config.NodeTTL, edns)
if records != nil {
resp.Answer = append(resp.Answer, records...)
}
if meta != nil && (qType == dns.TypeANY || qType == dns.TypeTXT) {
resp.Answer = append(resp.Answer, meta...)
} else if meta != nil && d.config.NodeMetaTXT {
resp.Extra = append(resp.Extra, meta...)
}
}
// encodeKVasRFC1464 encodes a key-value pair according to RFC1464
@ -619,8 +627,12 @@ func encodeKVasRFC1464(key, value string) (txt string) {
return key + "=" + value
}
// formatNodeRecord takes a Node and returns an A, AAAA, TXT or CNAME record
func (d *DNSServer) formatNodeRecord(node *structs.Node, addr, qName string, qType uint16, ttl time.Duration, edns, answer bool) (records []dns.RR) {
// formatNodeRecord takes a Node and returns the RRs associated with that node
//
// The return value is two slices. The first slice is the main answer slice (containing the A, AAAA, CNAME) RRs for the node
// and the second slice contains any TXT RRs created from the node metadata. It is up to the caller to determine where the
// generated RRs should go and if they should be used at all.
func (d *DNSServer) formatNodeRecord(node *structs.Node, addr, qName string, qType uint16, ttl time.Duration, edns bool) (records, meta []dns.RR) {
// Parse the IP
ip := net.ParseIP(addr)
var ipv4 net.IP
@ -681,26 +693,14 @@ func (d *DNSServer) formatNodeRecord(node *structs.Node, addr, qName string, qTy
}
}
node_meta_txt := false
if node == nil {
node_meta_txt = false
} else if answer {
node_meta_txt = true
} else {
// Use configuration when the TXT RR would
// end up in the Additional section of the
// DNS response
node_meta_txt = d.config.NodeMetaTXT
}
if node_meta_txt {
if node != nil {
for key, value := range node.Meta {
txt := value
if !strings.HasPrefix(strings.ToLower(key), "rfc1035-") {
txt = encodeKVasRFC1464(key, value)
}
records = append(records, &dns.TXT{
meta = append(meta, &dns.TXT{
Hdr: dns.RR_Header{
Name: qName,
Rrtype: dns.TypeTXT,
@ -712,7 +712,7 @@ func (d *DNSServer) formatNodeRecord(node *structs.Node, addr, qName string, qTy
}
}
return records
return records, meta
}
// indexRRs populates a map which indexes a given list of RRs by name. NOTE that
@ -1168,7 +1168,8 @@ func (d *DNSServer) serviceNodeRecords(dc string, nodes structs.CheckServiceNode
handled[addr] = struct{}{}
// Add the node record
records := d.formatNodeRecord(node.Node, addr, qName, qType, ttl, edns, true)
had_answer := false
records, meta := d.formatNodeRecord(node.Node, addr, qName, qType, ttl, edns)
if records != nil {
switch records[0].(type) {
case *dns.CNAME:
@ -1179,11 +1180,22 @@ func (d *DNSServer) serviceNodeRecords(dc string, nodes structs.CheckServiceNode
}
default:
resp.Answer = append(resp.Answer, records...)
count++
if count == d.config.ARecordLimit {
// We stop only if greater than 0 or we reached the limit
return
}
had_answer = true
}
}
if meta != nil && (qType == dns.TypeANY || qType == dns.TypeTXT) {
resp.Answer = append(resp.Answer, meta...)
had_answer = true
} else if meta != nil && d.config.NodeMetaTXT {
resp.Extra = append(resp.Extra, meta...)
}
if had_answer {
count++
if count == d.config.ARecordLimit {
// We stop only if greater than 0 or we reached the limit
return
}
}
}
@ -1230,7 +1242,7 @@ func (d *DNSServer) serviceSRVRecords(dc string, nodes structs.CheckServiceNodes
}
// Add the extra record
records := d.formatNodeRecord(node.Node, addr, srvRec.Target, dns.TypeANY, ttl, edns, false)
records, meta := d.formatNodeRecord(node.Node, addr, srvRec.Target, dns.TypeANY, ttl, edns)
if len(records) > 0 {
// Use the node address if it doesn't differ from the service address
if addr == node.Node.Address {
@ -1260,6 +1272,10 @@ func (d *DNSServer) serviceSRVRecords(dc string, nodes structs.CheckServiceNodes
resp.Extra = append(resp.Extra, records...)
}
}
if meta != nil && d.config.NodeMetaTXT {
resp.Extra = append(resp.Extra, meta...)
}
}
}
}

View File

@ -178,6 +178,9 @@ func TestDNS_NodeLookup(t *testing.T) {
TaggedAddresses: map[string]string{
"wan": "127.0.0.2",
},
NodeMeta: map[string]string{
"key": "value",
},
}
var out struct{}
@ -190,24 +193,40 @@ func TestDNS_NodeLookup(t *testing.T) {
c := new(dns.Client)
in, _, err := c.Exchange(m, a.DNSAddr())
if err != nil {
t.Fatalf("err: %v", err)
}
if len(in.Answer) != 1 {
t.Fatalf("Bad: %#v", in)
}
require.NoError(t, err)
require.Len(t, in.Answer, 2)
require.Len(t, in.Extra, 0)
aRec, ok := in.Answer[0].(*dns.A)
if !ok {
t.Fatalf("Bad: %#v", in.Answer[0])
}
if aRec.A.String() != "127.0.0.1" {
t.Fatalf("Bad: %#v", in.Answer[0])
}
if aRec.Hdr.Ttl != 0 {
t.Fatalf("Bad: %#v", in.Answer[0])
}
require.True(t, ok, "First answer is not an A record")
require.Equal(t, "127.0.0.1", aRec.A.String())
require.Equal(t, uint32(0), aRec.Hdr.Ttl)
txt, ok := in.Answer[1].(*dns.TXT)
require.True(t, ok, "Second answer is not a TXT record")
require.Len(t, txt.Txt, 1)
require.Equal(t, "key=value", txt.Txt[0])
// Re-do the query, but only for an A RR
m = new(dns.Msg)
m.SetQuestion("foo.node.consul.", dns.TypeA)
c = new(dns.Client)
in, _, err = c.Exchange(m, a.DNSAddr())
require.NoError(t, err)
require.Len(t, in.Answer, 1)
require.Len(t, in.Extra, 1)
aRec, ok = in.Answer[0].(*dns.A)
require.True(t, ok, "Answer is not an A record")
require.Equal(t, "127.0.0.1", aRec.A.String())
require.Equal(t, uint32(0), aRec.Hdr.Ttl)
txt, ok = in.Extra[0].(*dns.TXT)
require.True(t, ok, "Extra record is not a TXT record")
require.Len(t, txt.Txt, 1)
require.Equal(t, "key=value", txt.Txt[0])
// Re-do the query, but specify the DC
m = new(dns.Msg)
@ -215,24 +234,17 @@ func TestDNS_NodeLookup(t *testing.T) {
c = new(dns.Client)
in, _, err = c.Exchange(m, a.DNSAddr())
if err != nil {
t.Fatalf("err: %v", err)
}
if len(in.Answer) != 1 {
t.Fatalf("Bad: %#v", in)
}
require.NoError(t, err)
require.Len(t, in.Answer, 2)
require.Len(t, in.Extra, 0)
aRec, ok = in.Answer[0].(*dns.A)
if !ok {
t.Fatalf("Bad: %#v", in.Answer[0])
}
if aRec.A.String() != "127.0.0.1" {
t.Fatalf("Bad: %#v", in.Answer[0])
}
if aRec.Hdr.Ttl != 0 {
t.Fatalf("Bad: %#v", in.Answer[0])
}
require.True(t, ok, "First answer is not an A record")
require.Equal(t, "127.0.0.1", aRec.A.String())
require.Equal(t, uint32(0), aRec.Hdr.Ttl)
txt, ok = in.Answer[1].(*dns.TXT)
require.True(t, ok, "Second answer is not a TXT record")
// lookup a non-existing node, we should receive a SOA
m = new(dns.Msg)
@ -240,22 +252,11 @@ func TestDNS_NodeLookup(t *testing.T) {
c = new(dns.Client)
in, _, err = c.Exchange(m, a.DNSAddr())
if err != nil {
t.Fatalf("err: %v", err)
}
if len(in.Ns) != 1 {
t.Fatalf("Bad: %#v %#v", in, len(in.Answer))
}
require.NoError(t, err)
require.Len(t, in.Ns, 1)
soaRec, ok := in.Ns[0].(*dns.SOA)
if !ok {
t.Fatalf("Bad: %#v", in.Ns[0])
}
if soaRec.Hdr.Ttl != 0 {
t.Fatalf("Bad: %#v", in.Ns[0])
}
require.True(t, ok, "NS RR is not a SOA record")
require.Equal(t, uint32(0), soaRec.Hdr.Ttl)
}
func TestDNS_CaseInsensitiveNodeLookup(t *testing.T) {
@ -598,6 +599,41 @@ func TestDNS_NodeLookup_ANY_DontSuppressTXT(t *testing.T) {
verify.Values(t, "answer", in.Answer, wantAnswer)
}
func TestDNS_NodeLookup_A_SuppressTXT(t *testing.T) {
a := NewTestAgent(t.Name(), `dns_config = { enable_additional_node_meta_txt = false }`)
defer a.Shutdown()
args := &structs.RegisterRequest{
Datacenter: "dc1",
Node: "bar",
Address: "127.0.0.1",
NodeMeta: map[string]string{
"key": "value",
},
}
var out struct{}
require.NoError(t, a.RPC("Catalog.Register", args, &out))
m := new(dns.Msg)
m.SetQuestion("bar.node.consul.", dns.TypeA)
c := new(dns.Client)
in, _, err := c.Exchange(m, a.DNSAddr())
require.NoError(t, err)
wantAnswer := []dns.RR{
&dns.A{
Hdr: dns.RR_Header{Name: "bar.node.consul.", Rrtype: dns.TypeA, Class: dns.ClassINET, Rdlength: 0x4},
A: []byte{0x7f, 0x0, 0x0, 0x1}, // 127.0.0.1
},
}
verify.Values(t, "answer", in.Answer, wantAnswer)
// ensure TXT RR suppression
require.Len(t, in.Extra, 0)
}
func TestDNS_EDNS0(t *testing.T) {
t.Parallel()
a := NewTestAgent(t.Name(), "")

View File

@ -11,7 +11,7 @@ import (
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"github.com/armon/go-metrics"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/structs"
@ -19,6 +19,7 @@ import (
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/lib"
"github.com/hashicorp/consul/types"
"github.com/hashicorp/go-uuid"
)
// Config is the configuration for the State.
@ -1087,6 +1088,7 @@ func (l *State) deleteService(id string) error {
// todo(fs): some backoff strategy might be a better solution
l.services[id].InSync = true
l.logger.Printf("[WARN] agent: Service %q deregistration blocked by ACLs", id)
metrics.IncrCounter([]string{"acl", "blocked", "service", "deregistration"}, 1)
return nil
default:
@ -1124,6 +1126,7 @@ func (l *State) deleteCheck(id types.CheckID) error {
// todo(fs): some backoff strategy might be a better solution
l.checks[id].InSync = true
l.logger.Printf("[WARN] agent: Check %q deregistration blocked by ACLs", id)
metrics.IncrCounter([]string{"acl", "blocked", "check", "deregistration"}, 1)
return nil
default:
@ -1194,6 +1197,7 @@ func (l *State) syncService(id string) error {
l.checks[check.CheckID].InSync = true
}
l.logger.Printf("[WARN] agent: Service %q registration blocked by ACLs", id)
metrics.IncrCounter([]string{"acl", "blocked", "service", "registration"}, 1)
return nil
default:
@ -1239,6 +1243,7 @@ func (l *State) syncCheck(id types.CheckID) error {
// todo(fs): some backoff strategy might be a better solution
l.checks[id].InSync = true
l.logger.Printf("[WARN] agent: Check %q registration blocked by ACLs", id)
metrics.IncrCounter([]string{"acl", "blocked", "check", "registration"}, 1)
return nil
default:
@ -1270,6 +1275,7 @@ func (l *State) syncNodeInfo() error {
// todo(fs): some backoff strategy might be a better solution
l.nodeInfoInSync = true
l.logger.Printf("[WARN] agent: Node info update blocked by ACLs")
metrics.IncrCounter([]string{"acl", "blocked", "node", "registration"}, 1)
return nil
default:

View File

@ -433,6 +433,9 @@ func (m *Manager) newProxy(mp *local.ManagedProxy) (Proxy, error) {
return nil, fmt.Errorf("error configuring proxy logs: %s", err)
}
// Pass in the environmental variables for the proxy process
cmd.Env = os.Environ()
// Build the daemon structure
proxy.Command = &cmd
proxy.ProxyID = id

View File

@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"testing"
"time"
@ -261,6 +262,50 @@ func TestManagerRun_daemonPid(t *testing.T) {
require.NotEmpty(pidRaw)
}
// Test to check if the parent and the child processes
// have the same environmental variables
func TestManagerPassesEnvironment(t *testing.T) {
t.Parallel()
require := require.New(t)
state := local.TestState(t)
m, closer := testManager(t)
defer closer()
m.State = state
defer m.Kill()
// Add Proxy for the test
td, closer := testTempDir(t)
defer closer()
path := filepath.Join(td, "env-variables")
testStateProxy(t, state, "environTest", helperProcess("environ", path))
//Run the manager
go m.Run()
//Get the environmental variables from the OS
var fileContent []byte
var err error
var data []byte
envData := os.Environ()
sort.Strings(envData)
for _, envVariable := range envData {
data = append(data, envVariable...)
data = append(data, "\n"...)
}
// Check if the file written to from the spawned process
// has the necessary environmental variable data
retry.Run(t, func(r *retry.R) {
if fileContent, err = ioutil.ReadFile(path); err != nil {
r.Fatalf("No file ya dummy")
}
})
require.Equal(fileContent, data)
}
// Test the Snapshot/Restore works.
func TestManagerRun_snapshotRestore(t *testing.T) {
t.Parallel()

View File

@ -7,7 +7,9 @@ import (
"os"
"os/exec"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"testing"
"time"
@ -124,7 +126,6 @@ func TestHelperProcess(t *testing.T) {
default:
}
}
case "stop-kill":
// Setup listeners so it is ignored
ch := make(chan os.Signal, 1)
@ -139,6 +140,36 @@ func TestHelperProcess(t *testing.T) {
}
time.Sleep(25 * time.Millisecond)
}
// Check if the external process can access the enivironmental variables
case "environ":
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
defer signal.Stop(stop)
//Get the path for the file to be written to
path := args[0]
var data []byte
//Get the environmental variables
envData := os.Environ()
//Sort the env data for easier comparison
sort.Strings(envData)
for _, envVariable := range envData {
if strings.HasPrefix(envVariable, "CONSUL") || strings.HasPrefix(envVariable, "CONNECT") {
continue
}
data = append(data, envVariable...)
data = append(data, "\n"...)
}
if err := ioutil.WriteFile(path, data, 0644); err != nil {
t.Fatalf("[Error] File write failed : %s", err)
}
// Clean up after we receive the signal to exit
defer os.Remove(path)
<-stop
case "output":
fmt.Fprintf(os.Stdout, "hello stdout\n")

View File

@ -73,6 +73,11 @@ type CARoot struct {
// cannot be active.
Active bool
// RotatedOutAt is the time at which this CA was removed from the state.
// This will only be set on roots that have been rotated out from being the
// active root.
RotatedOutAt time.Time `json:"-"`
RaftIndex
}

View File

@ -5,5 +5,6 @@
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": false
"disableAnalytics": false,
"proxy": "http://localhost:3000"
}

View File

@ -1,28 +1,31 @@
ROOT:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
all: build
deps: node_modules
build: deps
yarn run build
start: deps
yarn run start
start-api: deps
yarn run start:api
test: deps
yarn run test
test-view: deps
yarn run test:view
lint: deps
yarn run lint:js
format: deps
yarn run format:js
node_modules: yarn.lock package.json
yarn install
.PHONY: all deps build start test test-view lint format

View File

@ -13,37 +13,27 @@ You will need the following things properly installed on your computer.
## Installation
* `git clone <repository-url>` this repository
* `cd ui`
* `git clone https://github.com/hashicorp/consul.git` this repository
* `cd ui-v2`
* `yarn install`
## Running / Development
* `yarn run start`
* `make start-api` or `yarn start:api` (this starts a Consul API double running
on http://localhost:3000)
* `make start` or `yarn start` to start the ember app that connects to the
above API double
* Visit your app at [http://localhost:4200](http://localhost:4200).
* Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests).
### Code Generators
Make use of the many generators for code, try `ember help generate` for more details
### Running Tests
* `ember test`
* `ember test --server`
You do not need to run `make start-api`/`yarn run start:api` to run the tests
### Building
* `ember build` (development)
* `ember build --environment production` (production)
### Deploying
## Further Reading / Useful Links
* [ember.js](https://emberjs.com/)
* [ember-cli](https://ember-cli.com/)
* Development Browser Extensions
* [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
* [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
* `make test` or `yarn run test`
* `make test-view` or `yarn run test:view` to view the tests running in Chrome

View File

@ -11,6 +11,7 @@ import { get } from '@ember/object';
import { inject as service } from '@ember/service';
import keyToArray from 'consul-ui/utils/keyToArray';
import removeNull from 'consul-ui/utils/remove-null';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/kv';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
@ -98,7 +99,7 @@ export default Adapter.extend({
break;
case this.isQueryRecord(url):
response = {
...response[0],
...removeNull(response[0]),
...{
[PRIMARY_KEY]: this.uidForURL(url),
},

View File

@ -1,6 +1,14 @@
import Adapter from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/node';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
// TODO: Looks like ID just isn't used at all
// consider just using .Node for the SLUG_KEY
const fillSlug = function(item) {
if (item[SLUG_KEY] === '') {
item[SLUG_KEY] = item['Node'];
}
return item;
};
export default Adapter.extend({
urlForQuery: function(query, modelName) {
return this.appendURL('internal/ui/nodes', [], this.cleanQuery(query));
@ -14,6 +22,7 @@ export default Adapter.extend({
const url = this.parseURL(requestData.url);
switch (true) {
case this.isQueryRecord(url):
response = fillSlug(response);
response = {
...response,
...{
@ -23,6 +32,7 @@ export default Adapter.extend({
break;
default:
response = response.map((item, i, arr) => {
item = fillSlug(item);
return {
...item,
...{

View File

@ -13,7 +13,11 @@ export default Model.extend({
[SLUG_KEY]: attr('string'),
LockIndex: attr('number'),
Flags: attr('number'),
Value: attr('string'),
// TODO: Consider defaulting all strings to '' because `typeof null !== 'string'`
// look into what other transformers do with `null` also
// preferably removeNull would be done in this layer also as if a property is `null`
// default Values don't kick in, which also explains `Tags` elsewhere
Value: attr('string'), //, {defaultValue: function() {return '';}}
CreateIndex: attr('string'),
ModifyIndex: attr('string'),
Session: attr('string'),

View File

@ -152,18 +152,18 @@
}
%with-right-arrow-grey {
@extend %pseudo-icon;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="13" height="11" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="%23919FA8" d="M7.526.219l-.958.924L10.4 4.84H0v1.32h10.4L6.568 9.857l.958.924L13 5.5z"/></svg>');
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="13" height="11" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="%23919FA8" d="M7.526.219l-.958.924L10.4 4.84H0v1.32h10.4L6.568 9.857l.958.924L13 5.5z"/></svg>');
}
%with-deny-icon {
@extend %pseudo-icon;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#282C2E" d="M8.79 4l-.737.71L11 7.556H3V8.57h8l-2.947 2.844.736.711L13 8.062z"/><rect stroke="#C73445" stroke-width="1.5" x=".75" y=".75" width="14.5" height="14.5" rx="7.25"/><path d="M3.5 3.5l9 9" stroke="#C73445" stroke-width="1.5" stroke-linecap="square"/></g></svg>');
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="%23282C2E" d="M8.79 4l-.737.71L11 7.556H3V8.57h8l-2.947 2.844.736.711L13 8.062z"/><rect stroke="%23C73445" stroke-width="1.5" x=".75" y=".75" width="14.5" height="14.5" rx="7.25"/><path d="M3.5 3.5l9 9" stroke="%23C73445" stroke-width="1.5" stroke-linecap="square"/></g></svg>');
width: 16px;
height: 16px;
background-color: transparent;
}
%with-deny-icon-grey {
@extend %pseudo-icon;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="14" height="14" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="%23919FA8" d="M7.79 2.992l-.737.711L10 6.547H2v1.016h8l-2.947 2.843.736.711L12 7.055z"/><rect stroke="#919FA8" stroke-width="1.5" x=".75" y=".75" width="12.5" height="12.5" rx="6.25"/><path d="M3.063 3.063l7.874 7.874" stroke="%23919FA8" stroke-width="1.5" stroke-linecap="square"/></g></svg>');
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="14" height="14" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="%23919FA8" d="M7.79 2.992l-.737.711L10 6.547H2v1.016h8l-2.947 2.843.736.711L12 7.055z"/><rect stroke="%23919FA8" stroke-width="1.5" x=".75" y=".75" width="12.5" height="12.5" rx="6.25"/><path d="M3.063 3.063l7.874 7.874" stroke="%23919FA8" stroke-width="1.5" stroke-linecap="square"/></g></svg>');
}
%with-deny::before {
@extend %with-deny-icon;

View File

@ -1,6 +1,6 @@
{{!<fieldset>}}
<label class="type-search">
<span>Search</span>
<input type="search" onsearch={{action onchange}} onkeyup={{action onchange}} name="s" value="{{value}}" placeholder="{{placeholder}}" autofocus="autofocus" />
<input type="search" onsearch={{action onchange}} oninput={{action onchange}} name="s" value="{{value}}" placeholder="{{placeholder}}" autofocus="autofocus" />
</label>
{{!</fieldset>}}

View File

@ -35,7 +35,7 @@
{{# if (and (not create) (not-eq item.ID 'anonymous')) }}
{{#confirmation-dialog message='Are you sure you want to delete this ACL token?'}}
{{#block-slot 'action' as |confirm|}}
<button type="button" class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
<button type="button" data-test-delete class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
<p>

View File

@ -1,7 +1,7 @@
{{#app-view class="acl edit" loading=isLoading}}
{{#block-slot 'breadcrumbs'}}
<ol>
<li><a href={{href-to 'dc.acls'}}>All Tokens</a></li>
<li><a data-test-back href={{href-to 'dc.acls'}}>All Tokens</a></li>
</ol>
{{/block-slot}}
{{#block-slot 'header'}}
@ -35,7 +35,7 @@
<button type="button" {{ action "clone" item }}>Clone token</button>
{{#confirmation-dialog message='Are you sure you want to use this ACL token?'}}
{{#block-slot 'action' as |confirm|}}
<button type="button" {{ action confirm 'use' item }}>Use token</button>
<button data-test-use type="button" {{ action confirm 'use' item }}>Use token</button>
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
<p>

View File

@ -5,7 +5,7 @@
</h1>
{{/block-slot}}
{{#block-slot 'actions'}}
<a href="{{href-to 'dc.acls.create'}}" class="type-create">Create</a>
<a data-test-create href="{{href-to 'dc.acls.create'}}" class="type-create">Create</a>
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}

View File

@ -64,7 +64,7 @@
{{# if (and item.ID (not-eq item.ID 'anonymous')) }}
{{#confirmation-dialog message='Are you sure you want to delete this Intention?'}}
{{#block-slot 'action' as |confirm|}}
<button type="button" class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
<button data-test-delete type="button" class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
<p>

View File

@ -1,7 +1,7 @@
{{#app-view class="acl edit" loading=isLoading}}
{{#block-slot 'breadcrumbs'}}
<ol>
<li><a href={{href-to 'dc.intentions'}}>All Intentions</a></li>
<li><a data-test-back href={{href-to 'dc.intentions'}}>All Intentions</a></li>
</ol>
{{/block-slot}}
{{#block-slot 'header'}}

View File

@ -5,7 +5,7 @@
</h1>
{{/block-slot}}
{{#block-slot 'actions'}}
<a href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
<a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
@ -27,7 +27,7 @@
{{/block-slot}}
{{#block-slot 'row'}}
<td class="source" data-test-intention="{{item.ID}}">
<a href={{href-to 'dc.intentions.edit' item.ID}}>
<a href={{href-to 'dc.intentions.edit' item.ID}} data-test-intention-source="{{item.SourceName}}">
{{#if (eq item.SourceName '*') }}
All Services (*)
{{else}}
@ -35,10 +35,10 @@
{{/if}}
</a>
</td>
<td class="intent-{{item.Action}}">
<td class="intent-{{item.Action}}" data-test-intention-action="{{item.Action}}">
<strong>{{item.Action}}</strong>
</td>
<td class="destination">
<td class="destination" data-test-intention-destination="{{item.DestinationName}}">
{{#if (eq item.DestinationName '*') }}
All Services (*)
{{else}}
@ -58,7 +58,7 @@
<a href={{href-to 'dc.intentions.edit' item.ID}}>Edit</a>
</li>
<li>
<a onclick={{action confirm 'delete' item}}>Delete</a>
<a data-test-delete onclick={{action confirm 'delete' item}}>Delete</a>
</li>
</ul>
{{/action-group}}

View File

@ -3,14 +3,14 @@
{{#if create }}
<label class="type-text{{if item.error.Key ' has-error'}}">
<span>Key or folder</span>
<input autofocus="autofocus" type="text" value={{left-trim item.Key parent.Key}} name="additional" onkeyup={{action 'change'}} placeholder="Key or folder" />
<input autofocus="autofocus" type="text" value={{left-trim item.Key parent.Key}} name="additional" oninput={{action 'change'}} placeholder="Key or folder" />
<em>To create a folder, end a key with <code>/</code></em>
</label>
{{/if}}
{{#if (or (eq (left-trim item.Key parent.Key) '') (not-eq (last item.Key) '/')) }}
<div>
<label class="type-toggle">
<input type="checkbox" name="json" checked={{if json 'checked' }} onchange={{action 'change'}} />
<input type="checkbox" name="json" checked={{if json 'checked' }} oninput={{action 'change'}} />
<span>Code</span>
</label>
<label class="type-text{{if item.error.Value ' has-error'}}">
@ -18,7 +18,7 @@
{{#if json}}
{{ code-editor value=(atob item.Value) onkeyup=(action 'change') }}
{{else}}
<textarea autofocus={{not create}} name="value" onkeyup={{action 'change'}}>{{atob item.Value}}</textarea>
<textarea autofocus={{not create}} name="value" oninput={{action 'change'}}>{{atob item.Value}}</textarea>
{{/if}}
</label>
</div>
@ -33,7 +33,7 @@
<button type="reset" {{ action "cancel" item parent}}>Cancel changes</button>
{{#confirmation-dialog message='Are you sure you want to delete this key?'}}
{{#block-slot 'action' as |confirm|}}
<button type="button" class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
<button data-test-delete type="button" class="type-delete" {{action confirm 'delete' item parent}}>Delete</button>
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
<p>

View File

@ -1,7 +1,7 @@
{{#app-view class="kv edit" loading=isLoading}}
{{#block-slot 'breadcrumbs'}}
<ol>
<li><a href={{href-to 'dc.kv.index'}}>Key / Values</a></li>
<li><a data-test-back href={{href-to 'dc.kv.index'}}>Key / Values</a></li>
{{#if (not-eq parent.Key '/') }}
{{#each (slice 0 -1 (split parent.Key '/')) as |breadcrumb index|}}
<li><a href={{href-to 'dc.kv.folder' (join '/' (append (slice 0 (add index 1) (split parent.Key '/')) ''))}}>{{breadcrumb}}</a></li>

View File

@ -27,9 +27,9 @@
{{/block-slot}}
{{#block-slot 'actions'}}
{{#if (not-eq parent.Key '/') }}
<a href="{{href-to 'dc.kv.create' parent.Key}}" class="type-create">Create</a>
<a data-test-create href="{{href-to 'dc.kv.create' parent.Key}}" class="type-create">Create</a>
{{else}}
<a href="{{href-to 'dc.kv.root-create'}}" class="type-create">Create</a>
<a data-test-create href="{{href-to 'dc.kv.root-create'}}" class="type-create">Create</a>
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}

View File

@ -37,7 +37,7 @@
<td>
{{#confirmation-dialog message='Are you sure you want to invalidate this session?'}}
{{#block-slot 'action' as |confirm|}}
<button type="button" class="type-delete" {{action confirm 'invalidateSession' item}}>Invalidate</button>
<button data-test-delete type="button" class="type-delete" {{action confirm 'invalidateSession' item}}>Invalidate</button>
{{/block-slot}}
{{#block-slot 'dialog' as |execute cancel message|}}
<p>

View File

@ -1,7 +1,7 @@
{{#app-view class="node show"}}
{{#block-slot 'breadcrumbs'}}
<ol>
<li><a href={{href-to 'dc.nodes'}}>All Nodes</a></li>
<li><a data-test-back href={{href-to 'dc.nodes'}}>All Nodes</a></li>
</ol>
{{/block-slot}}
{{#block-slot 'header'}}

View File

@ -1,7 +1,7 @@
{{#app-view class="service show"}}
{{#block-slot 'breadcrumbs'}}
<ol>
<li><a href={{href-to 'dc.services'}}>All Services</a></li>
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
</ol>
{{/block-slot}}
{{#block-slot 'header'}}

View File

@ -1,6 +1,7 @@
import TextEncoderLite from 'npm:text-encoder-lite';
import base64js from 'npm:base64-js';
export default function(str, encoding = 'utf-8') {
// str = String(str).trim();
//decode
const bytes = base64js.toByteArray(str);
return new (TextDecoder || TextEncoderLite)(encoding).decode(bytes);

View File

@ -0,0 +1,9 @@
export default function(obj) {
// non-recursive for the moment
return Object.keys(obj).reduce(function(prev, item, i, arr) {
if (obj[item] !== null) {
prev[item] = obj[item];
}
return prev;
}, {});
}

View File

@ -1,5 +1,4 @@
import { validatePresence, validateLength } from 'ember-changeset-validations/validators';
export default {
Key: [validatePresence(true), validateLength({ min: 1 })],
Value: validatePresence(true),
};

View File

@ -30,9 +30,9 @@ module.exports = function(environment) {
ENV = Object.assign({}, ENV, {
CONSUL_GIT_SHA: (function() {
if (process.env.CONSUL_GIT_SHA) {
return process.env.CONSUL_GIT_SHA
return process.env.CONSUL_GIT_SHA;
}
return require('child_process')
.execSync('git rev-parse --short HEAD')
.toString()
@ -40,7 +40,7 @@ module.exports = function(environment) {
})(),
CONSUL_VERSION: (function() {
if (process.env.CONSUL_VERSION) {
return process.env.CONSUL_VERSION
return process.env.CONSUL_VERSION;
}
// see /scripts/dist.sh:8
const version_go = `${path.dirname(path.dirname(__dirname))}/version/version.go`;
@ -53,13 +53,13 @@ module.exports = function(environment) {
.trim()
.split('"')[1];
})(),
CONSUL_BINARY_TYPE: (function() {
CONSUL_BINARY_TYPE: function() {
if (process.env.CONSUL_BINARY_TYPE) {
return process.env.CONSUL_BINARY_TYPE
return process.env.CONSUL_BINARY_TYPE;
}
return "oss"
}),
return 'oss';
},
CONSUL_DOCUMENTATION_URL: 'https://www.consul.io/docs',
CONSUL_COPYRIGHT_URL: 'https://www.hashicorp.com',
CONSUL_COPYRIGHT_YEAR: '2018',
@ -86,6 +86,10 @@ module.exports = function(environment) {
ENV.APP.rootElement = '#ember-testing';
ENV.APP.autoboot = false;
ENV['ember-cli-api-double'] = {
reader: 'html',
endpoints: ['/node_modules/@hashicorp/consul-api-double/v1'],
};
}
if (environment === 'production') {

View File

@ -1,8 +1,8 @@
{
"name": "consul-ui",
"version": "2.0.0",
"version": "2.2.0",
"private": true,
"description": "The web ui for Consul, by HashiCorp.",
"description": "The web UI for Consul, by HashiCorp.",
"directories": {
"doc": "doc",
"test": "tests"
@ -14,9 +14,9 @@
"lint:js": "eslint -c .eslintrc.js --fix ./*.js ./.*.js app config lib server tests",
"format:js": "prettier --write \"{app,config,lib,server,tests}/**/*.js\" ./*.js ./.*.js",
"start": "ember serve",
"test:sync": "rsync -aq ./node_modules/@hashicorp/consul-api-double/ ./public/consul-api-double/",
"test": "yarn run test:sync;ember test",
"test:view": "yarn run test:sync;ember test --server",
"start:api": "api-double --dir ./node_modules/@hashicorp/consul-api-double",
"test": "ember test",
"test:view": "ember test --server",
"precommit": "lint-staged"
},
"lint-staged": {
@ -29,10 +29,9 @@
"git add"
]
},
"dependencies": {},
"devDependencies": {
"@hashicorp/consul-api-double": "^1.0.0",
"@hashicorp/ember-cli-api-double": "^1.0.2",
"@hashicorp/consul-api-double": "^1.2.0",
"@hashicorp/ember-cli-api-double": "^1.3.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"base64-js": "^1.3.0",
"broccoli-asset-rev": "^2.4.5",

View File

@ -1,5 +1,5 @@
@setupApplicationTest
Feature: dc / components /acl filter: Acl Filter
Feature: components / acl filter: Acl Filter
In order to find the acl token I'm looking for easier
As a user
I should be able to filter by type and freetext search tokens by name and token
@ -25,12 +25,12 @@ Feature: dc / components /acl filter: Acl Filter
When I click all on the filter
Then I see allIsSelected on the filter
Then I type with yaml
Then I fill in with yaml
---
s: Anonymous Token
---
And I see 1 [Model] model with the name "Anonymous Token"
Then I type with yaml
Then I fill in with yaml
---
s: secret
---

View File

@ -47,7 +47,7 @@ Feature: components / catalog-filter
When I click all on the filter
And I see allIsSelected on the filter
Then I type with yaml
Then I fill in with yaml
---
s: [Model]-0
---
@ -75,7 +75,7 @@ Feature: components / catalog-filter
When I click services on the tabs
And I see servicesIsSelected on the tabs
Then I type with yaml
Then I fill in with yaml
---
s: 65535
---

View File

@ -0,0 +1,55 @@
@setupApplicationTest
Feature: components / intention filter: Intention Filter
In order to find the intention I'm looking for easier
As a user
I should be able to filter by 'policy' (allow/deny) and freetext search tokens by source and destination
Scenario: Filtering [Model]
Given 1 datacenter model with the value "dc-1"
And 2 [Model] models
When I visit the [Page] page for yaml
---
dc: dc-1
---
Then the url should be [Url]
Then I see 2 [Model] models
And I see allIsSelected on the filter
When I click allow on the filter
Then I see allowIsSelected on the filter
And I see 1 [Model] model
And I see 1 [Model] model with the action "allow"
When I click deny on the filter
Then I see denyIsSelected on the filter
And I see 1 [Model] model
And I see 1 [Model] model with the action "deny"
When I click all on the filter
Then I see 2 [Model] models
Then I see allIsSelected on the filter
Then I fill in with yaml
---
s: alarm
---
And I see 1 [Model] model
And I see 1 [Model] model with the source "alarm"
Then I fill in with yaml
---
s: feed
---
And I see 1 [Model] model
And I see 1 [Model] model with the destination "feed"
Then I fill in with yaml
---
s: transmitter
---
And I see 2 [Model] models
And I see 1 [Model] model with the source "transmitter"
And I see 1 [Model] model with the destination "transmitter"
Where:
---------------------------------------------
| Model | Page | Url |
| intention | intentions | /dc-1/intentions |
---------------------------------------------

View File

@ -12,7 +12,7 @@ Feature: components / kv-filter
dc: dc-1
---
Then the url should be [Url]
Then I type with yaml
Then I fill in with yaml
---
s: [Text]
---

View File

@ -0,0 +1,21 @@
@setupApplicationTest
Feature: components / text-input: Text input
Background:
Given 1 datacenter model with the value "dc-1"
Scenario:
When I visit the [Page] page for yaml
---
dc: dc-1
---
Then the url should be [Url]
Then I fill in with json
---
[Data]
---
Then I see submitIsEnabled
Where:
--------------------------------------------------------------------------------
| Page | Url | Data |
| kv | /dc-1/kv/create | {"additional": "hi", "value": "there"} |
| acl | /dc-1/acls/create | {"name": "hi"} |
--------------------------------------------------------------------------------

View File

@ -1,17 +0,0 @@
@setupApplicationTest
Feature: dc / acls / delete: ACL Delete
Scenario: Delete ACL
Given 1 datacenter model with the value "datacenter"
And 1 acl model from yaml
---
Name: something
ID: key
---
When I visit the acls page for yaml
---
dc: datacenter
---
And I click actions on the acls
And I click delete on the acls
And I click confirmDelete on the acls
Then a PUT request is made to "/v1/acl/destroy/key?dc=datacenter"

View File

@ -12,7 +12,7 @@ Feature: dc / acls / update: ACL Update
acl: key
---
Then the url should be /datacenter/acls/key
Then I type with yaml
Then I fill in with yaml
---
name: [Name]
---

View File

@ -0,0 +1,40 @@
@setupApplicationTest
Feature: dc / acls / use: Using an ACL token
Background:
Given 1 datacenter model with the value "datacenter"
And 1 acl model from yaml
---
ID: token
---
Scenario: Using an ACL token from the listing page
When I visit the acls page for yaml
---
dc: datacenter
---
Then I have settings like yaml
---
token: ~
---
And I click actions on the acls
And I click use on the acls
And I click confirmUse on the acls
Then I have settings like yaml
---
token: token
---
Scenario: Using an ACL token from the detail page
When I visit the acl page for yaml
---
dc: datacenter
acl: token
---
Then I have settings like yaml
---
token: ~
---
And I click use
And I click confirmUse
Then I have settings like yaml
---
token: token
---

View File

@ -1,16 +0,0 @@
@setupApplicationTest
Feature: dc / kvs / delete: KV Delete
Scenario: Delete ACL
Given 1 datacenter model with the value "datacenter"
And 1 kv model from yaml
---
- key-name
---
When I visit the kvs page for yaml
---
dc: datacenter
---
And I click actions on the kvs
And I click delete on the kvs
And I click confirmDelete on the kvs
Then a DELETE request is made to "/v1/kv/key-name?dc=datacenter"

View File

@ -1,7 +1,8 @@
@setupApplicationTest
Feature: dc / kvs / update: KV Update
Scenario: Update to [Name] change value to [Value]
Background:
Given 1 datacenter model with the value "datacenter"
Scenario: Update to [Name] change value to [Value]
And 1 kv model from yaml
---
Key: [Name]
@ -12,7 +13,7 @@ Feature: dc / kvs / update: KV Update
kv: [Name]
---
Then the url should be /datacenter/kv/[Name]/edit
Then I type with yaml
Then I fill in with yaml
---
value: [Value]
---
@ -25,6 +26,54 @@ Feature: dc / kvs / update: KV Update
| key-name | a value |
| folder/key-name | a value |
--------------------------------------------
Scenario: Update to a key change value to ' '
And 1 kv model from yaml
---
Key: key
---
When I visit the kv page for yaml
---
dc: datacenter
kv: key
---
Then the url should be /datacenter/kv/key/edit
Then I fill in with yaml
---
value: ' '
---
And I submit
Then a PUT request is made to "/v1/kv/key?dc=datacenter" with the body " "
Scenario: Update to a key change value to ''
And 1 kv model from yaml
---
Key: key
---
When I visit the kv page for yaml
---
dc: datacenter
kv: key
---
Then the url should be /datacenter/kv/key/edit
Then I fill in with yaml
---
value: ''
---
And I submit
Then a PUT request is made to "/v1/kv/key?dc=datacenter" with no body
Scenario: Update to a key when the value is empty
And 1 kv model from yaml
---
Key: key
Value: ~
---
When I visit the kv page for yaml
---
dc: datacenter
kv: key
---
Then the url should be /datacenter/kv/key/edit
And I submit
Then a PUT request is made to "/v1/kv/key?dc=datacenter" with no body
@ignore
Scenario: The feedback dialog says success or failure
Then ok

View File

@ -0,0 +1,33 @@
@setupApplicationTest
Feature: Hedge for if nodes come in over the API with no ID
Scenario: A node list with some missing IDs
Given 1 datacenter model with the value "dc-1"
And 5 node models from yaml
---
- ID: id-1
Node: name-1
- ID: ""
Node: name-2
- ID: ""
Node: name-3
- ID: ""
Node: name-4
- ID: ""
Node: name-5
---
When I visit the nodes page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/nodes
Then I see name on the nodes like yaml
---
- name-1
- name-2
- name-3
- name-4
- name-5
@ignore
Scenario: Visually comparing
Then the ".unhealthy" element should look like the "/node_modules/@hashicorp/consul-testing-extras/fixtures/dc/nodes/empty-ids.png" image

View File

@ -0,0 +1,26 @@
@setupApplicationTest
Feature: dc / nodes / sessions / invalidate: Invalidate Lock Sessions
In order to invalidate a lock session
As a user
I should be able to invalidate a lock session by clicking a button and confirming
Scenario: Given 2 lock sessions
Given 1 datacenter model with the value "dc1"
And 1 node model from yaml
---
- ID: node-0
---
And 2 session models from yaml
---
- ID: 7bbbd8bb-fff3-4292-b6e3-cfedd788546a
- ID: 7ccd0bd7-a5e0-41ae-a33e-ed3793d803b2
---
When I visit the node page for yaml
---
dc: dc1
node: node-0
---
And I click lockSessions on the tabs
Then I see lockSessionsIsSelected on the tabs
And I click delete on the sessions
And I click confirmDelete on the sessions
Then a PUT request is made to "/v1/session/destroy/7bbbd8bb-fff3-4292-b6e3-cfedd788546a?dc=dc1"

View File

@ -1,5 +1,5 @@
@setupApplicationTest
Feature: dc / nodes / sessions /list: List Lock Sessions
Feature: dc / nodes / sessions / list: List Lock Sessions
In order to get information regarding lock sessions
As a user
I should be able to see a listing of lock sessions with necessary information under the lock sessions tab for a node

View File

@ -0,0 +1,34 @@
@setupApplicationTest
Feature: deleting: Deleting from the listing and the detail page with confirmation
Scenario: Deleting a [Model] from the [Model] listing page
Given 1 datacenter model with the value "datacenter"
And 1 [Model] model from json
---
[Data]
---
When I visit the [Model]s page for yaml
---
dc: datacenter
---
And I click actions on the [Model]s
And I click delete on the [Model]s
And I click confirmDelete on the [Model]s
Then a [Method] request is made to "[URL]"
When I visit the [Model] page for yaml
---
dc: datacenter
[Slug]
---
And I click delete
And I click confirmDelete
Then a [Method] request is made to "[URL]"
Where:
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Model | Method | URL | Data | Slug |
| acl | PUT | /v1/acl/destroy/something?dc=datacenter | {"Name": "something", "ID": "something"} | acl: something |
| kv | DELETE | /v1/kv/key-name?dc=datacenter | ["key-name"] | kv: key-name |
| intention | DELETE | /v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter | {"SourceName": "name", "ID": "ee52203d-989f-4f7a-ab5a-2bef004164ca"} | intention: ee52203d-989f-4f7a-ab5a-2bef004164ca |
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
@ignore
Scenario: Sort out the wide tables ^
Then ok

View File

@ -8,36 +8,74 @@ Feature: Page Navigation
dc: dc-1
---
Then the url should be /dc-1/services
Scenario: Clicking [Link] in the navigation takes me to [Url]
Scenario: Clicking [Link] in the navigation takes me to [URL]
When I visit the services page for yaml
---
dc: dc-1
---
When I click [Link] on the navigation
Then the url should be [Url]
Then the url should be [URL]
Where:
--------------------------------------
| Link | Url |
| nodes | /dc-1/nodes |
| kvs | /dc-1/kv |
| acls | /dc-1/acls |
| settings | /settings |
--------------------------------------
Scenario: Clicking a [Item] in the [Model] listing
----------------------------------------
| Link | URL |
| nodes | /dc-1/nodes |
| kvs | /dc-1/kv |
| acls | /dc-1/acls |
| intentions | /dc-1/intentions |
| settings | /settings |
----------------------------------------
Scenario: Clicking a [Item] in the [Model] listing and back again
When I visit the [Model] page for yaml
---
dc: dc-1
---
When I click [Item] on the [Model]
Then the url should be [Url]
Then the url should be [URL]
And I click "[data-test-back]"
Then the url should be [Back]
Where:
--------------------------------------------------------
| Item | Model | Url |
| service | services | /dc-1/services/service-0 |
| node | nodes | /dc-1/nodes/node-0 |
| kv | kvs | /dc-1/kv/necessitatibus-0/edit |
| acl | acls | /dc-1/acls/anonymous |
--------------------------------------------------------
--------------------------------------------------------------------------------------------------------
| Item | Model | URL | Back |
| service | services | /dc-1/services/service-0 | /dc-1/services |
| node | nodes | /dc-1/nodes/node-0 | /dc-1/nodes |
| kv | kvs | /dc-1/kv/necessitatibus-0/edit | /dc-1/kv |
| acl | acls | /dc-1/acls/anonymous | /dc-1/acls |
| intention | intentions | /dc-1/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca | /dc-1/intentions |
--------------------------------------------------------------------------------------------------------
Scenario: Clicking a [Item] in the [Model] listing and canceling
When I visit the [Model] page for yaml
---
dc: dc-1
---
When I click [Item] on the [Model]
Then the url should be [URL]
And I click "[type=reset]"
Then the url should be [Back]
Where:
--------------------------------------------------------------------------------------------------------
| Item | Model | URL | Back |
| kv | kvs | /dc-1/kv/necessitatibus-0/edit | /dc-1/kv |
| acl | acls | /dc-1/acls/anonymous | /dc-1/acls |
| intention | intentions | /dc-1/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca | /dc-1/intentions |
--------------------------------------------------------------------------------------------------------
@ignore
Scenario: Clicking a kv in the kvs listing, without depending on the salt ^
Scenario: Clicking items in the listings, without depending on the salt ^
Then ok
Scenario: Clicking create in the [Model] listing
When I visit the [Model] page for yaml
---
dc: dc-1
---
When I click create
Then the url should be [URL]
And I click "[data-test-back]"
Then the url should be [Back]
Where:
------------------------------------------------------------------------
| Item | Model | URL | Back |
| kv | kvs | /dc-1/kv/create | /dc-1/kv |
| acl | acls | /dc-1/acls/create | /dc-1/acls |
| intention | intentions | /dc-1/intentions/create | /dc-1/intentions |
------------------------------------------------------------------------
Scenario: Using I click on should change the currentPage ^
Then ok

View File

@ -7,6 +7,10 @@ Feature: settings / update: Update Settings
Given 1 datacenter model with the value "datacenter"
When I visit the settings page
Then the url should be /settings
Then I have settings like yaml
---
token: ~
---
And I submit
Then I have settings like yaml
---

View File

@ -3,7 +3,6 @@ Feature: startup
In order to give users an indication as early as possible that they are at the right place
As a user
I should be able to see a startup logo
@ignore
Scenario: When loading the index.html file into a browser
Given 1 datacenter model with the value "dc-1"
Then the url should be ''

View File

@ -0,0 +1,10 @@
import steps from '../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from '../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from '../../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,11 @@
import steps from './steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert)
.then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -13,11 +13,12 @@ Feature: submit blank
And I submit
Then the url should be /datacenter/[Slug]/create
Where:
------------------
| Model | Slug |
| kv | kv |
| acl | acls |
------------------
--------------------------
| Model | Slug |
| kv | kv |
| acl | acls |
| intention | intentions |
--------------------------
@ignore
Scenario: The button is disabled
Then ok

View File

@ -16,7 +16,7 @@ Feature: token headers
Given 1 datacenter model with the value "datacenter"
When I visit the settings page
Then the url should be /settings
Then I type with yaml
Then I fill in with yaml
---
token: [Token]
---

View File

@ -1,4 +1,14 @@
import getAPI from '@hashicorp/ember-cli-api-double';
import setCookies from 'consul-ui/tests/helpers/set-cookies';
import typeToURL from 'consul-ui/tests/helpers/type-to-url';
export default getAPI('/consul-api-double', setCookies, typeToURL);
import config from 'consul-ui/config/environment';
const apiConfig = config['ember-cli-api-double'];
let path = '/consul-api-double';
let reader;
if (apiConfig) {
const temp = apiConfig.endpoints[0].split('/');
reader = apiConfig.reader;
temp.pop();
path = temp.join('/');
}
export default getAPI(path, setCookies, typeToURL, reader);

View File

@ -38,6 +38,7 @@ export default function(obj, stub) {
return _super;
},
});
// TODO: try/catch this?
const actual = cb();
Object.defineProperty(Object.getPrototypeOf(obj), '_super', {
set: function(val) {

View File

@ -1,34 +1,32 @@
export default function(type) {
let url = null;
let requests = null;
switch (type) {
case 'dc':
url = ['/v1/catalog/datacenters'];
requests = ['/v1/catalog/datacenters'];
break;
case 'service':
url = ['/v1/internal/ui/services', '/v1/health/service/'];
requests = ['/v1/internal/ui/services', '/v1/health/service/'];
break;
case 'node':
url = ['/v1/internal/ui/nodes'];
requests = ['/v1/internal/ui/nodes'];
break;
case 'kv':
url = '/v1/kv/';
requests = ['/v1/kv/'];
break;
case 'acl':
url = ['/v1/acl/list'];
requests = ['/v1/acl/list'];
break;
case 'session':
url = ['/v1/session/node/'];
requests = ['/v1/session/node/'];
break;
}
return function(actual) {
if (url === null) {
// TODO: An instance of URL should come in here (instead of 2 args)
return function(url, method) {
if (requests === null) {
return false;
}
if (typeof url === 'string') {
return url === actual;
}
return url.some(function(item) {
return actual.indexOf(item) === 0;
return requests.some(function(item) {
return method.toUpperCase() === 'GET' && url.indexOf(item) === 0;
});
};
}

View File

@ -3,6 +3,18 @@ import { skip } from 'qunit';
import { setupApplicationTest, setupRenderingTest, setupTest } from 'ember-qunit';
import api from 'consul-ui/tests/helpers/api';
const staticClassList = [...document.documentElement.classList];
function reset() {
window.localStorage.clear();
api.server.reset();
const list = document.documentElement.classList;
while (list.length > 0) {
list.remove(list.item(0));
}
staticClassList.forEach(function(item) {
list.add(item);
});
}
// this logic could be anything, but in this case...
// if @ignore, then return skip (for backwards compatibility)
// if have annotations in config, then only run those that have a matching annotation
@ -64,8 +76,7 @@ function setupScenario(featureAnnotations, scenarioAnnotations) {
}
return function(model) {
model.afterEach(function() {
window.localStorage.clear();
api.server.reset();
reset();
});
};
// return setupFn;

View File

@ -0,0 +1,11 @@
export default function(clickable, is) {
return function(obj) {
return {
...obj,
...{
cancel: clickable('[type=reset]'),
cancelIsEnabled: is(':not(:disabled)', '[type=reset]'),
},
};
};
}

View File

@ -0,0 +1,11 @@
export default function(clickable, is) {
return function(obj) {
return {
...obj,
...{
create: clickable('[data-test-create]'),
createIsEnabled: is(':not(:disabled)', '[data-test-create]'),
},
};
};
}

View File

@ -0,0 +1,11 @@
export default function(clickable) {
return function(obj) {
return {
...obj,
...{
delete: clickable('[data-test-delete]'),
confirmDelete: clickable('button.type-delete'),
},
};
};
}

View File

@ -0,0 +1,11 @@
export default function(clickable, is) {
return function(obj) {
return {
...obj,
...{
submit: clickable('[type=submit]'),
submitIsEnabled: is(':not(:disabled)', '[type=submit]'),
},
};
};
}

View File

@ -1,27 +1,48 @@
import { create, clickable, is, attribute, collection, text } from 'ember-cli-page-object';
import { visitable } from 'consul-ui/tests/lib/page-object/visitable';
import createDeletable from 'consul-ui/tests/lib/page-object/createDeletable';
import createSubmitable from 'consul-ui/tests/lib/page-object/createSubmitable';
import createCreatable from 'consul-ui/tests/lib/page-object/createCreatable';
import createCancelable from 'consul-ui/tests/lib/page-object/createCancelable';
import page from 'consul-ui/tests/pages/components/page';
import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup';
import index from 'consul-ui/tests/pages/index';
import dcs from 'consul-ui/tests/pages/dc';
import settings from 'consul-ui/tests/pages/settings';
import catalogFilter from 'consul-ui/tests/pages/components/catalog-filter';
import services from 'consul-ui/tests/pages/dc/services/index';
import service from 'consul-ui/tests/pages/dc/services/show';
import nodes from 'consul-ui/tests/pages/dc/nodes/index';
import node from 'consul-ui/tests/pages/dc/nodes/show';
import kvs from 'consul-ui/tests/pages/dc/kv/index';
import kv from 'consul-ui/tests/pages/dc/kv/edit';
import aclFilter from 'consul-ui/tests/pages/components/acl-filter';
import acls from 'consul-ui/tests/pages/dc/acls/index';
import acl from 'consul-ui/tests/pages/dc/acls/edit';
import intentionFilter from 'consul-ui/tests/pages/components/intention-filter';
import intentions from 'consul-ui/tests/pages/dc/intentions/index';
import intention from 'consul-ui/tests/pages/dc/intentions/edit';
const deletable = createDeletable(clickable);
const submitable = createSubmitable(clickable, is);
const creatable = createCreatable(clickable, is);
const cancelable = createCancelable(clickable, is);
export default {
index,
dcs,
settings,
services,
service,
nodes,
node,
kvs,
kv,
acls,
acl,
intention,
index: create(index(visitable, collection)),
dcs: create(dcs(visitable, clickable, attribute, collection)),
services: create(services(visitable, clickable, attribute, collection, page, catalogFilter)),
service: create(service(visitable, attribute, collection, text, catalogFilter)),
nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)),
node: create(node(visitable, deletable, clickable, attribute, collection, radiogroup)),
kvs: create(kvs(visitable, deletable, creatable, clickable, attribute, collection)),
kv: create(kv(visitable, submitable, deletable, cancelable, clickable)),
acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection, aclFilter)),
acl: create(acl(visitable, submitable, deletable, cancelable, clickable)),
intentions: create(
intentions(visitable, deletable, creatable, clickable, attribute, collection, intentionFilter)
),
intention: create(intention(visitable, submitable, deletable, cancelable)),
settings: create(settings(visitable, submitable)),
};

View File

@ -0,0 +1,9 @@
import { triggerable } from 'ember-cli-page-object';
import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup';
export default {
...radiogroup('action', ['', 'allow', 'deny']),
...{
scope: '[data-test-intention-filter]',
search: triggerable('keypress', '[name="s"]'),
},
};

View File

@ -1,6 +1,6 @@
import { clickable } from 'ember-cli-page-object';
export default {
navigation: ['services', 'nodes', 'kvs', 'acls', 'docs', 'settings'].reduce(
navigation: ['services', 'nodes', 'kvs', 'acls', 'intentions', 'docs', 'settings'].reduce(
function(prev, item, i, arr) {
const key = item;
return Object.assign({}, prev, {

View File

@ -1,9 +1,12 @@
import { create, visitable, attribute, collection, clickable } from 'ember-cli-page-object';
export default create({
visit: visitable('/:dc/'),
dcs: collection('[data-test-datacenter-picker]'),
showDatacenters: clickable('[data-test-datacenter-selected]'),
selectedDc: attribute('data-test-datacenter-selected', '[data-test-datacenter-selected]'),
selectedDatacenter: attribute('data-test-datacenter-selected', '[data-test-datacenter-selected]'),
});
export default function(visitable, clickable, attribute, collection) {
return {
visit: visitable('/:dc/'),
dcs: collection('[data-test-datacenter-picker]'),
showDatacenters: clickable('[data-test-datacenter-selected]'),
selectedDc: attribute('data-test-datacenter-selected', '[data-test-datacenter-selected]'),
selectedDatacenter: attribute(
'data-test-datacenter-selected',
'[data-test-datacenter-selected]'
),
};
}

View File

@ -1,10 +1,11 @@
import { create, clickable, triggerable } from 'ember-cli-page-object';
import { visitable } from 'consul-ui/tests/lib/page-object/visitable';
export default create({
// custom visitable
visit: visitable(['/:dc/acls/:acl', '/:dc/acls/create']),
// fillIn: fillable('input, textarea, [contenteditable]'),
name: triggerable('keypress', '[name="name"]'),
submit: clickable('[type=submit]'),
});
export default function(visitable, submitable, deletable, cancelable, clickable) {
return submitable(
cancelable(
deletable({
visit: visitable(['/:dc/acls/:acl', '/:dc/acls/create']),
use: clickable('[data-test-use]'),
confirmUse: clickable('button.type-delete'),
})
)
);
}

View File

@ -1,14 +1,16 @@
import { create, visitable, collection, attribute, clickable } from 'ember-cli-page-object';
import filter from 'consul-ui/tests/pages/components/acl-filter';
export default create({
visit: visitable('/:dc/acls'),
acls: collection('[data-test-tabular-row]', {
name: attribute('data-test-acl', '[data-test-acl]'),
acl: clickable('a'),
actions: clickable('label'),
delete: clickable('[data-test-delete]'),
confirmDelete: clickable('button.type-delete'),
}),
filter: filter,
});
export default function(visitable, deletable, creatable, clickable, attribute, collection, filter) {
return creatable({
visit: visitable('/:dc/acls'),
acls: collection(
'[data-test-tabular-row]',
deletable({
name: attribute('data-test-acl', '[data-test-acl]'),
acl: clickable('a'),
actions: clickable('label'),
use: clickable('[data-test-use]'),
confirmUse: clickable('button.type-delete'),
})
),
filter: filter,
});
}

View File

@ -1,8 +1,9 @@
import { create, clickable } from 'ember-cli-page-object';
import { visitable } from 'consul-ui/tests/lib/page-object/visitable';
export default create({
// custom visitable
visit: visitable(['/:dc/intentions/:intention', '/:dc/intentions/create']),
submit: clickable('[type=submit]'),
});
export default function(visitable, submitable, deletable, cancelable) {
return submitable(
cancelable(
deletable({
visit: visitable(['/:dc/intentions/:intention', '/:dc/intentions/create']),
})
)
);
}

View File

@ -0,0 +1,19 @@
export default function(visitable, deletable, creatable, clickable, attribute, collection, filter) {
return creatable({
visit: visitable('/:dc/intentions'),
intentions: collection(
'[data-test-tabular-row]',
deletable({
source: attribute('data-test-intention-source', '[data-test-intention-source]'),
destination: attribute(
'data-test-intention-destination',
'[data-test-intention-destination]'
),
action: attribute('data-test-intention-action', '[data-test-intention-action]'),
intention: clickable('a'),
actions: clickable('label'),
})
),
filter: filter,
});
}

View File

@ -1,10 +1,9 @@
import { create, clickable } from 'ember-cli-page-object';
import { visitable } from 'consul-ui/tests/lib/page-object/visitable';
export default create({
// custom visitable
visit: visitable(['/:dc/kv/:kv/edit', '/:dc/kv/create'], str => str),
// fillIn: fillable('input, textarea, [contenteditable]'),
// name: triggerable('keypress', '[name="additional"]'),
submit: clickable('[type=submit]'),
});
export default function(visitable, submitable, deletable, cancelable) {
return submitable(
cancelable(
deletable({
visit: visitable(['/:dc/kv/:kv/edit', '/:dc/kv/create'], str => str),
})
)
);
}

View File

@ -1,12 +1,13 @@
import { create, visitable, collection, attribute, clickable } from 'ember-cli-page-object';
export default create({
visit: visitable('/:dc/kv'),
kvs: collection('[data-test-tabular-row]', {
name: attribute('data-test-kv', '[data-test-kv]'),
kv: clickable('a'),
actions: clickable('label'),
delete: clickable('[data-test-delete]'),
confirmDelete: clickable('button.type-delete'),
}),
});
export default function(visitable, deletable, creatable, clickable, attribute, collection) {
return creatable({
visit: visitable('/:dc/kv'),
kvs: collection(
'[data-test-tabular-row]',
deletable({
name: attribute('data-test-kv', '[data-test-kv]'),
kv: clickable('a'),
actions: clickable('label'),
})
),
});
}

View File

@ -1,11 +1,10 @@
import { create, visitable, collection, attribute, clickable } from 'ember-cli-page-object';
import filter from 'consul-ui/tests/pages/components/catalog-filter';
export default create({
visit: visitable('/:dc/nodes'),
nodes: collection('[data-test-node]', {
name: attribute('data-test-node'),
node: clickable('header a'),
}),
filter: filter,
});
export default function(visitable, clickable, attribute, collection, filter) {
return {
visit: visitable('/:dc/nodes'),
nodes: collection('[data-test-node]', {
name: attribute('data-test-node'),
node: clickable('header a'),
}),
filter: filter,
};
}

View File

@ -1,16 +1,18 @@
import { create, visitable, collection, attribute } from 'ember-cli-page-object';
import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup';
export default create({
visit: visitable('/:dc/nodes/:node'),
tabs: radiogroup('tab', ['health-checks', 'services', 'round-trip-time', 'lock-sessions']),
healthchecks: collection('[data-test-node-healthcheck]', {
name: attribute('data-test-node-healthcheck'),
}),
services: collection('#services [data-test-tabular-row]', {
port: attribute('data-test-service-port', '.port'),
}),
sessions: collection('#lock-sessions [data-test-tabular-row]', {
TTL: attribute('data-test-session-ttl', '[data-test-session-ttl]'),
}),
});
export default function(visitable, deletable, clickable, attribute, collection, radiogroup) {
return {
visit: visitable('/:dc/nodes/:node'),
tabs: radiogroup('tab', ['health-checks', 'services', 'round-trip-time', 'lock-sessions']),
healthchecks: collection('[data-test-node-healthcheck]', {
name: attribute('data-test-node-healthcheck'),
}),
services: collection('#services [data-test-tabular-row]', {
port: attribute('data-test-service-port', '.port'),
}),
sessions: collection(
'#lock-sessions [data-test-tabular-row]',
deletable({
TTL: attribute('data-test-session-ttl', '[data-test-session-ttl]'),
})
),
};
}

View File

@ -1,16 +1,12 @@
import { create, visitable, collection, attribute, clickable } from 'ember-cli-page-object';
import page from 'consul-ui/tests/pages/components/page';
import filter from 'consul-ui/tests/pages/components/catalog-filter';
export default create({
visit: visitable('/:dc/services'),
services: collection('[data-test-service]', {
name: attribute('data-test-service'),
service: clickable('a'),
}),
dcs: collection('[data-test-datacenter-picker]'),
navigation: page.navigation,
filter: filter,
});
export default function(visitable, clickable, attribute, collection, page, filter) {
return {
visit: visitable('/:dc/services'),
services: collection('[data-test-service]', {
name: attribute('data-test-service'),
service: clickable('a'),
}),
dcs: collection('[data-test-datacenter-picker]'),
navigation: page.navigation,
filter: filter,
};
}

View File

@ -1,18 +1,17 @@
import { create, visitable, collection, attribute, text } from 'ember-cli-page-object';
import filter from 'consul-ui/tests/pages/components/catalog-filter';
export default create({
visit: visitable('/:dc/services/:service'),
nodes: collection('[data-test-node]', {
name: attribute('data-test-node'),
}),
healthy: collection('[data-test-healthy] [data-test-node]', {
name: attribute('data-test-node'),
address: text('header strong'),
}),
unhealthy: collection('[data-test-unhealthy] [data-test-node]', {
name: attribute('data-test-node'),
address: text('header strong'),
}),
filter: filter,
});
export default function(visitable, attribute, collection, text, filter) {
return {
visit: visitable('/:dc/services/:service'),
nodes: collection('[data-test-node]', {
name: attribute('data-test-node'),
}),
healthy: collection('[data-test-healthy] [data-test-node]', {
name: attribute('data-test-node'),
address: text('header strong'),
}),
unhealthy: collection('[data-test-unhealthy] [data-test-node]', {
name: attribute('data-test-node'),
address: text('header strong'),
}),
filter: filter,
};
}

View File

@ -1,6 +1,6 @@
import { create, visitable, collection } from 'ember-cli-page-object';
export default create({
visit: visitable('/'),
dcs: collection('[data-test-datacenter-list]'),
});
export default function(visitable, collection) {
return {
visit: visitable('/'),
dcs: collection('[data-test-datacenter-list]'),
};
}

View File

@ -1,6 +1,5 @@
import { create, visitable, clickable } from 'ember-cli-page-object';
export default create({
visit: visitable('/settings'),
submit: clickable('[type=submit]'),
});
export default function(visitable, submitable) {
return submitable({
visit: visitable('/settings'),
});
}

View File

@ -5,6 +5,8 @@ import getDictionary from '@hashicorp/ember-cli-api-double/dictionary';
import pages from 'consul-ui/tests/pages';
import api from 'consul-ui/tests/helpers/api';
// const dont = `( don't| shouldn't| can't)?`;
const create = function(number, name, value) {
// don't return a promise here as
// I don't need it to wait
@ -83,6 +85,15 @@ export default function(assert) {
.when('I click "$selector"', function(selector) {
return click(selector);
})
// TODO: Probably nicer to think of better vocab than having the 'without " rule'
.when('I click (?!")$property(?!")', function(property) {
try {
return currentPage[property]();
} catch (e) {
console.error(e);
throw new Error(`The '${property}' property on the page object doesn't exist`);
}
})
.when('I click $prop on the $component', function(prop, component) {
// Collection
var obj;
@ -207,12 +218,20 @@ export default function(assert) {
);
assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`);
const body = request.requestBody;
assert.equal(
body,
data,
`Expected the request body to be ${body}, was ${request.requestBody}`
);
assert.equal(body, data, `Expected the request body to be ${data}, was ${body}`);
})
.then('a $method request is made to "$url" with no body', function(method, url) {
const request = api.server.history[api.server.history.length - 2];
assert.equal(
request.method,
method,
`Expected the request method to be ${method}, was ${request.method}`
);
assert.equal(request.url, url, `Expected the request url to be ${url}, was ${request.url}`);
const body = request.requestBody;
assert.equal(body, null, `Expected the request body to be null, was ${body}`);
})
.then('a $method request is made to "$url"', function(method, url) {
const request = api.server.history[api.server.history.length - 2];
assert.equal(
@ -240,7 +259,9 @@ export default function(assert) {
assert.equal(len, num, `Expected ${num} ${model}s, saw ${len}`);
})
.then(['I see $num $model model with the $property "$value"'], function(
// TODO: I${ dont } see
.then([`I see $num $model model[s]? with the $property "$value"`], function(
// negate,
num,
model,
property,
@ -335,7 +356,7 @@ export default function(assert) {
`Expected to not see ${property} on ${component}`
);
})
.then(['I see $property'], function(property, component) {
.then(['I see $property'], function(property) {
assert.ok(currentPage[property], `Expected to see ${property}`);
})
.then(['I see the text "$text" in "$selector"'], function(text, selector) {

View File

@ -46,7 +46,7 @@ module('Unit | Adapter | kv', function(hooks) {
const uid = {
uid: JSON.stringify([dc, expected]),
};
const actual = adapter.handleResponse(200, {}, uid, { url: url });
const actual = adapter.handleResponse(200, {}, [uid], { url: url });
assert.deepEqual(actual, uid);
});
});

View File

@ -0,0 +1,67 @@
import { module } from 'ember-qunit';
import test from 'ember-sinon-qunit/test-support/test';
import { skip } from 'qunit';
import atob from 'consul-ui/utils/atob';
module('Unit | Utils | atob', {});
skip('it decodes non-strings properly', function(assert) {
[
{
test: ' ',
expected: '',
},
{
test: new String(),
expected: '',
},
{
test: new String('MTIzNA=='),
expected: '1234',
},
{
test: [],
expected: '',
},
{
test: [' '],
expected: '',
},
{
test: new Array(),
expected: '',
},
{
test: ['MTIzNA=='],
expected: '1234',
},
{
test: null,
expected: '<27><>e',
},
].forEach(function(item) {
const actual = atob(item.test);
assert.equal(actual, item.expected);
});
});
test('it decodes strings properly', function(assert) {
[
{
test: '',
expected: '',
},
{
test: 'MTIzNA==',
expected: '1234',
},
].forEach(function(item) {
const actual = atob(item.test);
assert.equal(actual, item.expected);
});
});
test('throws when passed the wrong value', function(assert) {
[{}, ['MTIz', 'NA=='], new Number(), 'hi'].forEach(function(item) {
assert.throws(function() {
atob(item);
});
});
});

View File

@ -0,0 +1,49 @@
import removeNull from 'consul-ui/utils/remove-null';
import { skip } from 'qunit';
import { module, test } from 'qunit';
module('Unit | Utility | remove null');
test('it removes null valued properties shallowly', function(assert) {
[
{
test: {
Value: null,
},
expected: {},
},
{
test: {
Key: 'keyname',
Value: null,
},
expected: {
Key: 'keyname',
},
},
{
test: {
Key: 'keyname',
Value: '',
},
expected: {
Key: 'keyname',
Value: '',
},
},
{
test: {
Key: 'keyname',
Value: false,
},
expected: {
Key: 'keyname',
Value: false,
},
},
].forEach(function(item) {
const actual = removeNull(item.test);
assert.deepEqual(actual, item.expected);
});
});
skip('it removes null valued properties deeply');

View File

@ -69,9 +69,9 @@
dependencies:
"@glimmer/di" "^0.2.0"
"@hashicorp/api-double@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@hashicorp/api-double/-/api-double-1.2.0.tgz#d2846f79d086ac009673ae755da15301e0f2f7c3"
"@hashicorp/api-double@^1.3.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@hashicorp/api-double/-/api-double-1.4.0.tgz#17ddad8e55370de0d24151a38c5f029bc207cafe"
dependencies:
"@gardenhq/o" "^8.0.1"
"@gardenhq/tick-control" "^2.0.0"
@ -81,20 +81,21 @@
faker "^4.1.0"
js-yaml "^3.10.0"
"@hashicorp/consul-api-double@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-1.1.0.tgz#658f9e89208fa23f251ca66c66aeb7241a13f23f"
"@hashicorp/consul-api-double@^1.2.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-1.3.0.tgz#fded48ca4db1e63c66e39b4433b2169b6add69ed"
"@hashicorp/ember-cli-api-double@^1.0.2":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-1.2.0.tgz#aed3a9659abb3f3c56d77e400abc7fcbdcf2b78b"
"@hashicorp/ember-cli-api-double@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@hashicorp/ember-cli-api-double/-/ember-cli-api-double-1.3.0.tgz#d07b5b11701cd55d6b01cb8a47ce17c4bac21fed"
dependencies:
"@hashicorp/api-double" "^1.1.0"
"@hashicorp/api-double" "^1.3.0"
array-range "^1.0.1"
ember-cli-babel "^6.6.0"
js-yaml "^3.11.0"
merge-options "^1.0.1"
pretender "^2.0.0"
recursive-readdir-sync "^1.0.6"
"@sinonjs/formatio@^2.0.0":
version "2.0.0"
@ -7735,6 +7736,10 @@ recast@^0.11.17, recast@^0.11.3:
private "~0.1.5"
source-map "~0.5.0"
recursive-readdir-sync@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/recursive-readdir-sync/-/recursive-readdir-sync-1.0.6.tgz#1dbf6d32f3c5bb8d3cde97a6c588d547a9e13d56"
redent@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"

View File

@ -79,4 +79,7 @@ the community.
<li>
<a href="https://github.com/cpageler93/ConsulSwift">ConsulSwift</a> - Swift client for the Consul HTTP API
</li>
<li>
<a href="https://github.com/oatpp/oatpp-consul">oatpp-consul</a> - C++ Consul integration for <a href="https://oatpp.io/">oatpp</a> applications
</li>
</ul>

View File

@ -136,7 +136,7 @@ will exit with an error at startup.
* <a name="_config_dir"></a><a href="#_config_dir">`-config-dir`</a> - A directory of
configuration files to load. Consul will
load all files in this directory with the suffix ".json". The load order
load all files in this directory with the suffix ".json" or ".hcl". The load order
is alphabetical, and the the same merge routine is used as with the
[`config-file`](#_config_file) option above. This option can be specified multiple times
to load multiple directories. Sub-directories of the config directory are not loaded.
@ -496,11 +496,14 @@ Consul will not enable TLS for the HTTP API unless the `https` port has been ass
to enable ACL support.
* <a name="acl_down_policy"></a><a href="#acl_down_policy">`acl_down_policy`</a> - Either
"allow", "deny" or "extend-cache"; "extend-cache" is the default. In the case that the
"allow", "deny", "extend-cache" or "async-cache"; "extend-cache" is the default. In the case that the
policy for a token cannot be read from the [`acl_datacenter`](#acl_datacenter) or leader
node, the down policy is applied. In "allow" mode, all actions are permitted, "deny" restricts
all operations, and "extend-cache" allows any cached ACLs to be used, ignoring their TTL
values. If a non-cached ACL is used, "extend-cache" acts like "deny".
The value "async-cache" acts the same way as "extend-cache" but performs updates
asynchronously when ACL is present but its TTL is expired, thus, if latency is bad between
ACL authoritative and other datacenters, latency of operations is not impacted.
* <a name="acl_agent_master_token"></a><a href="#acl_agent_master_token">`acl_agent_master_token`</a> -
Used to access <a href="/api/agent.html">agent endpoints</a> that require agent read
@ -822,7 +825,7 @@ Consul will not enable TLS for the HTTP API unless the `https` port has been ass
matching hosts, shuffle the list randomly, and then limit the number of
answers to `a_record_limit` (default: no limit). This limit does not apply to SRV records.
In environments where [RFC 3484 Section 6](https://tools.ietf.org/html/rfc3484#section-6) Rule 9
In environments where [RFC 3484 Section 6](https://tools.ietf.org/html/rfc3484#section-6) Rule 9
is implemented and enforced (i.e. DNS answers are always sorted and
therefore never random), clients may need to set this value to `1` to
preserve the expected randomized distribution behavior (note:
@ -831,7 +834,7 @@ Consul will not enable TLS for the HTTP API unless the `https` port has been ass
be increasingly uncommon to need to change this value with modern
resolvers).
* <a name="enable_additional_node_meta_txt"></a><a href="#enable_additional_node_meta_txt">`enable_additional_node_meta_txt`</a> -
* <a name="enable_additional_node_meta_txt"></a><a href="#enable_additional_node_meta_txt">`enable_additional_node_meta_txt`</a> -
When set to true, Consul will add TXT records for Node metadata into the Additional section of the DNS responses for several
query types such as SRV queries. When set to false those records are emitted. This does not impact the behavior of those
same TXT records when they would be added to the Answer section of the response like when querying with type TXT or ANY. This

Some files were not shown because too many files have changed in this diff Show More